postalcode-geocode 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mg-solutions
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # postalcode-geocode
2
+
3
+ Resolve **global postal / ZIP codes** to latitude, longitude, and IANA timezone using [GeoNames](https://www.geonames.org/) data.
4
+
5
+ The npm package is small. On first use (or `postalcode-geocode setup`) it downloads the GeoNames postal dump (~19 MB), builds a local SQLite cache, then works **offline**.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install postalcode-geocode
11
+ ```
12
+
13
+ Requires **Node.js ≥ 22.5** (uses built-in `node:sqlite`).
14
+
15
+ ## Setup
16
+
17
+ ```bash
18
+ npx postalcode-geocode setup
19
+ ```
20
+
21
+ Or the first `lookupPostalCode()` call will download automatically.
22
+
23
+ Data directory:
24
+
25
+ - Windows: `%LOCALAPPDATA%\postalcode-geocode\`
26
+ - macOS/Linux: `~/.cache/postalcode-geocode/`
27
+ - Override: `POSTALCODE_GEOCODE_DATA_DIR`
28
+
29
+ ## Usage
30
+
31
+ ```js
32
+ import { lookupPostalCode, toHebcalLocation } from 'postalcode-geocode';
33
+
34
+ const place = await lookupPostalCode('10952');
35
+ // {
36
+ // postalCode: '10952',
37
+ // countryCode: 'US',
38
+ // placeName: 'Monsey',
39
+ // admin1: 'New York',
40
+ // latitude: 41.11…,
41
+ // longitude: -74.08…,
42
+ // tzid: 'America/New_York',
43
+ // accuracy: …
44
+ // }
45
+
46
+ // Optional: convert for @hebcal/core zmanim
47
+ import '@hebcal/core'; // peer dependency
48
+ const location = await toHebcalLocation(place);
49
+ ```
50
+
51
+ ### API
52
+
53
+ | Function | Description |
54
+ |----------|-------------|
55
+ | `lookupPostalCode(code, countryCode?)` | Look up a postal/ZIP. US 5-digit codes work without country. |
56
+ | `ensureData({ force? })` | Download + build cache |
57
+ | `getDataPath()` / `dataReady()` | Cache location / readiness |
58
+ | `toHebcalLocation(result)` | Build `@hebcal/core` `Location` (optional peer) |
59
+
60
+ ### CLI
61
+
62
+ ```bash
63
+ postalcode-geocode setup
64
+ postalcode-geocode lookup 10952
65
+ postalcode-geocode lookup SW1A GB
66
+ postalcode-geocode path
67
+ ```
68
+
69
+ ## Attribution
70
+
71
+ Postal code data © [GeoNames](https://www.geonames.org/) under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). Please credit GeoNames if you use this package.
72
+
73
+ Timezone resolution via [`geo-tz`](https://www.npmjs.com/package/geo-tz).
74
+
75
+ ## Notes
76
+
77
+ - UK / Canada / Netherlands entries in `allCountries.zip` may be outward codes only (not full unit-level postcodes). See the [GeoNames postal readme](https://download.geonames.org/export/zip/readme.txt).
78
+ - Coordinates are estimates from GeoNames (accuracy field 1–6).
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "postalcode-geocode",
3
+ "version": "1.0.0",
4
+ "description": "Resolve global postal/ZIP codes to lat/lon/timezone using GeoNames (download-once local cache)",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js"
9
+ },
10
+ "bin": {
11
+ "postalcode-geocode": "./src/cli.js"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "engines": {
19
+ "node": ">=22.5.0"
20
+ },
21
+ "scripts": {
22
+ "setup": "node src/cli.js setup",
23
+ "test": "node --test test/*.test.js",
24
+ "prepublishOnly": "node --test test/*.test.js"
25
+ },
26
+ "keywords": [
27
+ "postal",
28
+ "zip",
29
+ "geocode",
30
+ "geonames",
31
+ "timezone",
32
+ "coordinates",
33
+ "hebcal"
34
+ ],
35
+ "author": "mg-solutions",
36
+ "license": "MIT",
37
+ "dependencies": {
38
+ "geo-tz": "^8.1.8",
39
+ "yauzl": "^3.2.0"
40
+ },
41
+ "peerDependencies": {
42
+ "@hebcal/core": ">=5.0.0"
43
+ },
44
+ "peerDependenciesMeta": {
45
+ "@hebcal/core": {
46
+ "optional": true
47
+ }
48
+ }
49
+ }
package/src/cli.js ADDED
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ ensureData,
4
+ lookupPostalCode,
5
+ getDataPath,
6
+ getDbPath,
7
+ dataReady,
8
+ } from './index.js';
9
+
10
+ function printHelp() {
11
+ console.log(`postalcode-geocode — GeoNames postal/ZIP → lat/lon/timezone
12
+
13
+ Usage:
14
+ postalcode-geocode setup Download GeoNames data + build local cache
15
+ postalcode-geocode setup --force Rebuild even if cache exists
16
+ postalcode-geocode lookup <code> [CC] Look up a postal code (CC = country, e.g. US)
17
+ postalcode-geocode path Print data directory
18
+ postalcode-geocode help
19
+
20
+ Examples:
21
+ postalcode-geocode setup
22
+ postalcode-geocode lookup 10952
23
+ postalcode-geocode lookup 10952 US
24
+ postalcode-geocode lookup SW1A GB
25
+ `);
26
+ }
27
+
28
+ async function main(argv) {
29
+ const cmd = argv[0] || 'help';
30
+
31
+ if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
32
+ printHelp();
33
+ return;
34
+ }
35
+
36
+ if (cmd === 'path') {
37
+ console.log(getDataPath());
38
+ console.log('db:', getDbPath());
39
+ console.log('ready:', dataReady());
40
+ return;
41
+ }
42
+
43
+ if (cmd === 'setup') {
44
+ const force = argv.includes('--force');
45
+ const result = await ensureData({
46
+ force,
47
+ onProgress: (event, info) => {
48
+ if (event === 'cache-hit') {
49
+ console.log('Cache already present:', info.path);
50
+ } else if (event === 'download-start') {
51
+ console.log('Downloading', info.url, '…');
52
+ } else if (event === 'download-done') {
53
+ console.log('Download complete.');
54
+ } else if (event === 'build-start') {
55
+ console.log('Building SQLite index…');
56
+ } else if (event === 'build-progress') {
57
+ console.log(` … ${info.rows} rows`);
58
+ } else if (event === 'index-start') {
59
+ console.log(`Creating indexes (${info.rows} rows)…`);
60
+ } else if (event === 'done') {
61
+ console.log(`Done. ${info.rows} postal codes → ${info.path}`);
62
+ }
63
+ },
64
+ });
65
+ if (result.cached) {
66
+ console.log('Nothing to do.');
67
+ }
68
+ return;
69
+ }
70
+
71
+ if (cmd === 'lookup') {
72
+ const code = argv[1];
73
+ const country = argv[2];
74
+ if (!code) {
75
+ console.error('Usage: postalcode-geocode lookup <code> [countryCode]');
76
+ process.exit(1);
77
+ }
78
+ const result = await lookupPostalCode(code, country);
79
+ console.log(JSON.stringify(result, null, 2));
80
+ return;
81
+ }
82
+
83
+ console.error(`Unknown command: ${cmd}`);
84
+ printHelp();
85
+ process.exit(1);
86
+ }
87
+
88
+ main(process.argv.slice(2)).catch((err) => {
89
+ console.error(err.message || err);
90
+ process.exit(1);
91
+ });
@@ -0,0 +1,178 @@
1
+ import fs from 'node:fs';
2
+ import fsp from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { createWriteStream } from 'node:fs';
5
+ import { pipeline } from 'node:stream/promises';
6
+ import { createInterface } from 'node:readline';
7
+ import { Readable } from 'node:stream';
8
+ import { DatabaseSync } from 'node:sqlite';
9
+ import yauzl from 'yauzl';
10
+ import { getDataPath, getDbPath, getMetaPath, dataReady } from './paths.js';
11
+
12
+ export const GEONAMES_ZIP_URL =
13
+ 'https://download.geonames.org/export/zip/allCountries.zip';
14
+
15
+ const DATA_VERSION = 1;
16
+
17
+ function openZip(filePath) {
18
+ return new Promise((resolve, reject) => {
19
+ yauzl.open(filePath, { lazyEntries: true }, (err, zipfile) => {
20
+ if (err) reject(err);
21
+ else resolve(zipfile);
22
+ });
23
+ });
24
+ }
25
+
26
+ function zipReadStream(zipfile, entry) {
27
+ return new Promise((resolve, reject) => {
28
+ zipfile.openReadStream(entry, (err, stream) => {
29
+ if (err) reject(err);
30
+ else resolve(stream);
31
+ });
32
+ });
33
+ }
34
+
35
+ async function downloadFile(url, destPath) {
36
+ const res = await fetch(url);
37
+ if (!res.ok) {
38
+ throw new Error(`Failed to download ${url} (HTTP ${res.status})`);
39
+ }
40
+ await pipeline(Readable.fromWeb(res.body), createWriteStream(destPath));
41
+ }
42
+
43
+ /**
44
+ * Download GeoNames postal dump and build a local SQLite cache.
45
+ * Safe to call repeatedly; rebuilds by default when force=true or missing.
46
+ */
47
+ export async function ensureData({ force = false, onProgress } = {}) {
48
+ if (!force && dataReady()) {
49
+ onProgress?.('cache-hit', { path: getDbPath() });
50
+ return { path: getDbPath(), cached: true };
51
+ }
52
+
53
+ const dataDir = getDataPath();
54
+ await fsp.mkdir(dataDir, { recursive: true });
55
+
56
+ const zipPath = path.join(dataDir, 'allCountries.zip');
57
+ const dbPath = getDbPath();
58
+ const tmpDbPath = `${dbPath}.building`;
59
+
60
+ onProgress?.('download-start', { url: GEONAMES_ZIP_URL });
61
+ await downloadFile(GEONAMES_ZIP_URL, zipPath);
62
+ onProgress?.('download-done', { path: zipPath });
63
+
64
+ if (fs.existsSync(tmpDbPath)) await fsp.unlink(tmpDbPath);
65
+ if (fs.existsSync(dbPath)) await fsp.unlink(dbPath);
66
+
67
+ onProgress?.('build-start', {});
68
+ const db = new DatabaseSync(tmpDbPath);
69
+ db.exec(`
70
+ PRAGMA journal_mode = OFF;
71
+ PRAGMA synchronous = OFF;
72
+ CREATE TABLE postal_codes (
73
+ country_code TEXT NOT NULL,
74
+ postal_code TEXT NOT NULL,
75
+ place_name TEXT,
76
+ admin1 TEXT,
77
+ admin1_code TEXT,
78
+ admin2 TEXT,
79
+ latitude REAL NOT NULL,
80
+ longitude REAL NOT NULL,
81
+ accuracy INTEGER
82
+ );
83
+ `);
84
+
85
+ const insert = db.prepare(`
86
+ INSERT INTO postal_codes (
87
+ country_code, postal_code, place_name, admin1, admin1_code, admin2,
88
+ latitude, longitude, accuracy
89
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
90
+ `);
91
+
92
+ const zipfile = await openZip(zipPath);
93
+ let rows = 0;
94
+
95
+ await new Promise((resolve, reject) => {
96
+ zipfile.on('error', reject);
97
+ zipfile.on('end', resolve);
98
+ zipfile.on('entry', async (entry) => {
99
+ try {
100
+ if (!/allCountries\.txt$/i.test(entry.fileName)) {
101
+ zipfile.readEntry();
102
+ return;
103
+ }
104
+
105
+ const stream = await zipReadStream(zipfile, entry);
106
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
107
+
108
+ db.exec('BEGIN');
109
+ for await (const line of rl) {
110
+ if (!line) continue;
111
+ const parts = line.split('\t');
112
+ if (parts.length < 12) continue;
113
+
114
+ const country = parts[0];
115
+ const postal = parts[1];
116
+ const place = parts[2] || null;
117
+ const admin1 = parts[3] || null;
118
+ const admin1Code = parts[4] || null;
119
+ const admin2 = parts[5] || null;
120
+ const lat = Number(parts[9]);
121
+ const lon = Number(parts[10]);
122
+ const accuracy = parts[11] ? Number(parts[11]) : null;
123
+
124
+ if (!country || !postal || Number.isNaN(lat) || Number.isNaN(lon)) continue;
125
+
126
+ insert.run(
127
+ country,
128
+ postal,
129
+ place,
130
+ admin1,
131
+ admin1Code,
132
+ admin2,
133
+ lat,
134
+ lon,
135
+ Number.isFinite(accuracy) ? accuracy : null,
136
+ );
137
+ rows += 1;
138
+ if (rows % 100000 === 0) {
139
+ onProgress?.('build-progress', { rows });
140
+ }
141
+ }
142
+ db.exec('COMMIT');
143
+ zipfile.readEntry();
144
+ } catch (err) {
145
+ reject(err);
146
+ }
147
+ });
148
+ zipfile.readEntry();
149
+ });
150
+
151
+ onProgress?.('index-start', { rows });
152
+ db.exec(`
153
+ CREATE INDEX idx_postal ON postal_codes (postal_code);
154
+ CREATE INDEX idx_country_postal ON postal_codes (country_code, postal_code);
155
+ `);
156
+ db.close();
157
+
158
+ await fsp.rename(tmpDbPath, dbPath);
159
+
160
+ const meta = {
161
+ version: DATA_VERSION,
162
+ source: GEONAMES_ZIP_URL,
163
+ attribution: 'GeoNames.org (CC BY 4.0)',
164
+ builtAt: new Date().toISOString(),
165
+ rows,
166
+ };
167
+ await fsp.writeFile(getMetaPath(), JSON.stringify(meta, null, 2));
168
+
169
+ // Keep zip for debugging/rebuild; optional cleanup
170
+ try {
171
+ await fsp.unlink(zipPath);
172
+ } catch {
173
+ // ignore
174
+ }
175
+
176
+ onProgress?.('done', { path: dbPath, rows });
177
+ return { path: dbPath, cached: false, rows };
178
+ }
package/src/hebcal.js ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Convert a postalcode-geocode result into a @hebcal/core Location.
3
+ * Requires optional peer dependency `@hebcal/core`.
4
+ */
5
+ export async function toHebcalLocation(result) {
6
+ let Location;
7
+ try {
8
+ ({ Location } = await import('@hebcal/core'));
9
+ } catch {
10
+ throw new Error(
11
+ 'toHebcalLocation() requires peer dependency @hebcal/core. Install it with: npm install @hebcal/core',
12
+ );
13
+ }
14
+
15
+ const nameParts = [
16
+ result.placeName,
17
+ result.admin1Code || result.admin1,
18
+ result.postalCode,
19
+ ].filter(Boolean);
20
+ const cityName = nameParts.join(', ');
21
+ const il = result.countryCode === 'IL';
22
+
23
+ const loc = new Location(
24
+ result.latitude,
25
+ result.longitude,
26
+ il,
27
+ result.tzid,
28
+ cityName,
29
+ result.countryCode,
30
+ result.postalCode,
31
+ );
32
+ loc.geo = 'zip';
33
+ loc.zip = result.postalCode;
34
+ loc.admin1 = result.admin1Code || result.admin1;
35
+ loc.stateName = result.admin1;
36
+ return loc;
37
+ }
package/src/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { lookupPostalCode, closeDb } from './lookup.js';
2
+ export { ensureData, GEONAMES_ZIP_URL } from './ensureData.js';
3
+ export { getDataPath, getDbPath, getMetaPath, dataReady } from './paths.js';
4
+ export { toHebcalLocation } from './hebcal.js';
package/src/lookup.js ADDED
@@ -0,0 +1,139 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { find as findTimezone } from 'geo-tz';
3
+ import { ensureData } from './ensureData.js';
4
+ import { getDbPath, dataReady } from './paths.js';
5
+
6
+ let dbSingleton = null;
7
+
8
+ function getDb() {
9
+ if (!dbSingleton) {
10
+ dbSingleton = new DatabaseSync(getDbPath(), { readOnly: true });
11
+ }
12
+ return dbSingleton;
13
+ }
14
+
15
+ /** Close the open DB handle (mainly for tests). */
16
+ export function closeDb() {
17
+ if (dbSingleton) {
18
+ dbSingleton.close();
19
+ dbSingleton = null;
20
+ }
21
+ }
22
+
23
+ function normalizePostalCode(code) {
24
+ return String(code).trim().toUpperCase();
25
+ }
26
+
27
+ function normalizeCountry(cc) {
28
+ if (!cc) return null;
29
+ return String(cc).trim().toUpperCase();
30
+ }
31
+
32
+ function inferCountryFromCode(code) {
33
+ // Bare 5-digit (or ZIP+4) → assume US
34
+ if (/^\d{5}(-\d{4})?$/.test(code)) return 'US';
35
+ return null;
36
+ }
37
+
38
+ function rowToResult(row) {
39
+ const latitude = row.latitude;
40
+ const longitude = row.longitude;
41
+ const zones = findTimezone(latitude, longitude);
42
+ const tzid = zones?.[0];
43
+ if (!tzid) {
44
+ throw new Error(
45
+ `Could not resolve timezone for ${row.postal_code} at ${latitude},${longitude}`,
46
+ );
47
+ }
48
+
49
+ return {
50
+ postalCode: row.postal_code,
51
+ countryCode: row.country_code,
52
+ placeName: row.place_name || null,
53
+ admin1: row.admin1 || null,
54
+ admin1Code: row.admin1_code || null,
55
+ admin2: row.admin2 || null,
56
+ latitude,
57
+ longitude,
58
+ tzid,
59
+ accuracy: row.accuracy ?? null,
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Look up a postal/ZIP code.
65
+ *
66
+ * @param {string} code Postal or ZIP code (e.g. "10952", "M5V 3L9")
67
+ * @param {string} [countryCode] ISO-3166 alpha-2 (e.g. "US", "CA"). Optional for US 5-digit ZIPs.
68
+ * @param {{ ensure?: boolean }} [options] ensure=true (default) downloads GeoNames data if missing
69
+ * @returns {Promise<object>}
70
+ */
71
+ export async function lookupPostalCode(code, countryCode, options = {}) {
72
+ // Support lookupPostalCode(code, { ensure: false })
73
+ let opts = options;
74
+ let cc = countryCode;
75
+ if (cc && typeof cc === 'object' && !Array.isArray(cc)) {
76
+ opts = cc;
77
+ cc = undefined;
78
+ }
79
+
80
+ const { ensure = true } = opts;
81
+ const postal = normalizePostalCode(code);
82
+ if (!postal) {
83
+ throw new Error('postal code is required');
84
+ }
85
+
86
+ let country = normalizeCountry(cc) || inferCountryFromCode(postal);
87
+
88
+ // US ZIP+4 → use 5-digit prefix for GeoNames
89
+ let queryPostal = postal;
90
+ if (country === 'US' && /^\d{5}-\d{4}$/.test(postal)) {
91
+ queryPostal = postal.slice(0, 5);
92
+ }
93
+
94
+ if (ensure || !dataReady()) {
95
+ if (!dataReady()) {
96
+ await ensureData();
97
+ }
98
+ } else if (!dataReady()) {
99
+ throw new Error(
100
+ 'Postal code data is not installed. Run `npx postalcode-geocode setup` first.',
101
+ );
102
+ }
103
+
104
+ const db = getDb();
105
+
106
+ let rows;
107
+ if (country) {
108
+ rows = db
109
+ .prepare(
110
+ `SELECT * FROM postal_codes
111
+ WHERE country_code = ? AND postal_code = ?
112
+ ORDER BY accuracy DESC NULLS LAST
113
+ LIMIT 5`,
114
+ )
115
+ .all(country, queryPostal);
116
+ } else {
117
+ rows = db
118
+ .prepare(
119
+ `SELECT * FROM postal_codes
120
+ WHERE postal_code = ?
121
+ ORDER BY accuracy DESC NULLS LAST
122
+ LIMIT 5`,
123
+ )
124
+ .all(queryPostal);
125
+ }
126
+
127
+ if (!rows.length) {
128
+ const where = country ? `${country} ${queryPostal}` : queryPostal;
129
+ throw new Error(`Unknown postal code: ${where}`);
130
+ }
131
+
132
+ // If multiple countries matched without country filter, prefer US then first
133
+ let chosen = rows[0];
134
+ if (!country && rows.length > 1) {
135
+ chosen = rows.find((r) => r.country_code === 'US') || rows[0];
136
+ }
137
+
138
+ return rowToResult(chosen);
139
+ }
package/src/paths.js ADDED
@@ -0,0 +1,32 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ const PACKAGE_DIR = 'postalcode-geocode';
6
+ const DB_FILENAME = 'postalcodes.sqlite';
7
+ const META_FILENAME = 'meta.json';
8
+
9
+ /** Directory for the downloaded GeoNames SQLite cache. */
10
+ export function getDataPath() {
11
+ if (process.env.POSTALCODE_GEOCODE_DATA_DIR) {
12
+ return path.resolve(process.env.POSTALCODE_GEOCODE_DATA_DIR);
13
+ }
14
+ if (process.platform === 'win32') {
15
+ const base = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
16
+ return path.join(base, PACKAGE_DIR);
17
+ }
18
+ const xdg = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
19
+ return path.join(xdg, PACKAGE_DIR);
20
+ }
21
+
22
+ export function getDbPath() {
23
+ return path.join(getDataPath(), DB_FILENAME);
24
+ }
25
+
26
+ export function getMetaPath() {
27
+ return path.join(getDataPath(), META_FILENAME);
28
+ }
29
+
30
+ export function dataReady() {
31
+ return fs.existsSync(getDbPath()) && fs.existsSync(getMetaPath());
32
+ }