├── .gitignore ├── wrangler.toml ├── package.json ├── tsconfig.json ├── data └── import.psql ├── src └── index.ts ├── README.md └── presentation └── index.html /.gitignore: -------------------------------------------------------------------------------- 1 | .dev.vars 2 | .wrangler 3 | node_modules 4 | -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "places" 2 | main = "src/index.ts" 3 | compatibility_date = "2022-11-01" 4 | 5 | [vars] 6 | # DATABASE_URL 7 | # Run `wrangler secret put DATABASE_URL` and enter the connection string when prompted. 8 | # And/or for local dev, create `.dev.vars` containing `DATABASE_URL=postgres://mysecretconnstr`. 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "neon-cf-pg-test", 3 | "version": "0.1.0", 4 | "description": "", 5 | "main": "index.js", 6 | "author": "George MacKerron", 7 | "license": "MIT", 8 | "devDependencies": { 9 | "@cloudflare/workers-types": "^4.20221111.1" 10 | }, 11 | "dependencies": { 12 | "@neondatabase/serverless": "^0.4.9" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 4 | "lib": ["ES2022"] /* Specify a set of bundled library declaration files that describe the target runtime environment. */, 5 | "module": "ES2022" /* Specify what module code is generated. */, 6 | "moduleResolution": "Node" /* Specify how TypeScript looks up a file from a given module specifier. */, 7 | "types": ["@cloudflare/workers-types"] /* Specify type package names to be included without being referenced in a source file. */, 8 | "resolveJsonModule": true /* Enable importing .json files */, 9 | "allowJs": true /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */, 10 | "checkJs": false /* Enable error reporting in type-checked JavaScript files. */, 11 | "noEmit": true /* Disable emitting files from a compilation. */, 12 | "isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */, 13 | "allowSyntheticDefaultImports": true /* Allow 'import x from y' when a module doesn't have a default export. */, 14 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 15 | "strict": true /* Enable all strict type-checking options. */, 16 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 17 | } 18 | } -------------------------------------------------------------------------------- /data/import.psql: -------------------------------------------------------------------------------- 1 | -- == create schema == -- 2 | 3 | begin; 4 | 5 | drop type if exists category cascade; 6 | create type category as enum ('Natural', 'Cultural', 'Mixed'); 7 | 8 | drop type if exists category_short cascade; 9 | create type category_short as enum ('N', 'C', 'C/N'); 10 | 11 | drop table if exists whc_sites_2021; 12 | create table whc_sites_2021 ( 13 | unique_number integer primary key, 14 | id_no integer not null, 15 | rev_bis text, 16 | name_en text not null, 17 | name_fr text not null, 18 | short_description_en text not null, 19 | short_description_fr text not null, 20 | justification_en text, 21 | justification_fr text, 22 | date_inscribed integer not null, 23 | secondary_dates text, 24 | danger integer, 25 | date_end integer, 26 | danger_list text, 27 | longitude float not null, 28 | latitude float not null, 29 | area_hectares float, 30 | c1 int not null, 31 | c2 int not null, 32 | c3 int not null, 33 | c4 int not null, 34 | c5 int not null, 35 | c6 int not null, 36 | n7 int not null, 37 | n8 int not null, 38 | n9 int not null, 39 | n10 int not null, 40 | criteria_txt text not null, 41 | category category not null, 42 | category_short category_short not null, 43 | states_name_en text not null, 44 | states_name_fr text not null, 45 | region_en text not null, 46 | region_fr text not null, 47 | iso_code text, 48 | udnp_code text, 49 | transboundary int not null 50 | ); 51 | 52 | commit; 53 | 54 | 55 | -- == import data == -- 56 | 57 | \copy whc_sites_2021 from 'data/whc-sites-2021.csv' csv header 58 | 59 | 60 | -- == create geography column + index == -- 61 | 62 | create extension postgis; 63 | alter table whc_sites_2021 add column location geography(point); 64 | update whc_sites_2021 set location = st_setsrid(st_makepoint(longitude, latitude), 4326)::geography; 65 | create index loc_idx on whc_sites_2021 using gist (location); 66 | analyze whc_sites_2021; 67 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '@neondatabase/serverless'; 2 | interface Env { DATABASE_URL: string } 3 | 4 | export default { 5 | async fetch(request: Request, env: Env, ctx: ExecutionContext) { 6 | // try to extract longitude and latitude from path 7 | let url; 8 | try { url = new URL(request.url); } 9 | catch { url = { pathname: '/' }; } 10 | const [, urlLongitude, , urlLatitude] = url.pathname.match(/^\/(-?\d{1,3}(\.\d+)?)\/(-?\d{1,2}(\.\d+)?)$/) ?? []; 11 | 12 | // fill in missing location data from IP or defaults 13 | const cf = request.cf ?? {} as any; 14 | const round2dp = (n: number) => Math.round(n * 100) / 100; // round for caching 15 | const longitude = round2dp(parseFloat(urlLongitude ?? cf.longitude ?? '-122.473831')); 16 | const latitude = round2dp(parseFloat(urlLatitude ?? cf.latitude ?? '37.818496')); 17 | const location = urlLongitude ? 'via browser geolocation' : 18 | cf.city ? `via IP address in ${cf.city}, ${cf.country}` : 19 | 'unknown, assuming San Francisco'; 20 | 21 | let nearestSites; 22 | let cached; 23 | 24 | // check cache 25 | const cacheKey = `https://whs.cache.neon.tech/${longitude}/${latitude}/data.js`; 26 | const cachedResponse = await caches.default.match(cacheKey); 27 | 28 | if (cachedResponse) { 29 | cached = true; 30 | nearestSites = await cachedResponse.json(); 31 | 32 | } else { 33 | // connect and query database 34 | const client = new Client(env.DATABASE_URL); 35 | await client.connect(); 36 | 37 | const { rows } = await client.query(` 38 | select 39 | id_no, name_en, category, 40 | st_makepoint($1, $2) <-> location as distance 41 | from whc_sites_2021 42 | order by distance limit 10`, 43 | [longitude, latitude] 44 | ); // no cast needed: PostGIS casts geometry -> geography, never the reverse: https://gis.stackexchange.com/a/367374 45 | 46 | ctx.waitUntil(client.end()); 47 | 48 | cached = false; 49 | nearestSites = rows; 50 | 51 | // cache result 52 | ctx.waitUntil(caches.default.put(cacheKey, 53 | new Response(JSON.stringify(nearestSites), { headers: { 54 | 'Content-Type': 'application/json', 55 | 'Cache-Control': 'public, max-age=86400' /* 24 hours */, 56 | } }), 57 | )); 58 | } 59 | 60 | // respond! 61 | const responseJson = JSON.stringify({ viaIp: !urlLongitude, longitude, latitude, location, cached, nearestSites }, null, 2); 62 | return new Response(responseJson, { headers: { 63 | 'Content-Type': 'application/json', 64 | 'Access-Control-Allow-Origin': '*', 65 | }}); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `@neondatabase/serverless` example: query Neon PostgreSQL from a Cloudflare Worker 2 | 3 | This repo provides an example of using Neon's [`@neondatabase/serverless`](https://www.npmjs.com/@neondatabase/serverless) driver package to query PostgreSQL from a Cloudflare Worker, and to cache the results. 4 | 5 | ## The app 6 | 7 | The example is a web app that returns a list of your nearest UNESCO World Heritage Sites. 8 | 9 | * You can see the app deployed at https://places-neon-demo.pages.dev/ (this is static HTML file `presentation/index.html` deployed to Cloudflare Pages). 10 | 11 | * You can see the JSON data it fetches at https://places.neon-demo.workers.dev/ (this is `index.ts` deployed as a Cloudflare Worker) 12 | 13 | Please note that the UNESCO data is copyright © 1992 – 2022 UNESCO/World Heritage Centre. All rights reserved. 14 | 15 | ## The driver 16 | 17 | Neon's `@neondatabase/serverless` driver is based on and offers the same API as the [node-postgres](https://node-postgres.com/) package, which is what you get with `npm install pg`. 18 | 19 | We've simply shimmed the Node libraries it requires, and replaced `net.Socket` and `tls.connect` with implementations that encrypt and transfer the data over WebSockets. 20 | 21 | Find out more about the driver from the [`@neondatabase/serverless` README on npmjs.com](https://www.npmjs.com/@neondatabase/serverless) or [GitHub](https://github.com/neondatabase/serverless). 22 | 23 | ## How to run 24 | 25 | To run this app locally: 26 | 27 | * __Get the data__ — Download [the Excel listing](https://whc.unesco.org/en/list/xls/?2021) from [the UNESCO Syndication page](https://whc.unesco.org/en/syndication/). Open the `.xls` file and save it as a `.csv`. 28 | 29 | * __Create the database__ — Create a new project in the Neon dashboard, and connect to it securely using `psql` (substituting your own PostgreSQL connection string, of course): 30 | 31 | ``` 32 | mkdir -p $HOME/.postgresql 33 | 34 | curl https://letsencrypt.org/certs/isrgrootx1.pem > $HOME/.postgresql/isrgrootx1.pem 35 | 36 | psql "postgresql://user:password@project-name-1234.cloud.neon.tech:5432/main?sslmode=verify-full&sslrootcert=$HOME/.postgresql/isrgrootx1.pem" 37 | ``` 38 | 39 | * __Load the data__ — Run the SQL commands in `data/import.psql` against your database within `psql`. 40 | 41 | * __Set environment variables__ — Create a file `.dev.vars`, setting the environment variable `DATABASE_URL` to the PostgreSQL connection string you'll find in your Neon dashboard. That should look something like this: 42 | 43 | `DATABASE_URL=postgresql://user:password@project-name-1234.cloud.neon.tech:5432/main` 44 | 45 | * __Install and run__ 46 | 47 | ```bash 48 | npm install 49 | npm install -g wrangler@latest 50 | 51 | npx wrangler dev --local 52 | ``` 53 | 54 | Edit `presentation/index.html` to fetch from `http://localhost:8787`, and open that address in your browser. 55 | 56 | * __Deploy__ 57 | 58 | ```bash 59 | npx wrangler secret put DATABASE_URL # same connection string as in `.dev.vars` 60 | npx wrangler publish 61 | ``` 62 | 63 | (You may then need to edit `presentation/index.html` to fetch from the deployed endpoint). 64 | 65 | 66 | ## Feedback and support 67 | 68 | Please visit [Neon Community](https://community.neon.tech/) or [Support](https://neon.tech/docs/introduction/support). 69 | -------------------------------------------------------------------------------- /presentation/index.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |