mpb-localkit 1.3.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 +21 -0
- package/README.md +375 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +853 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/core/index.d.ts +404 -0
- package/dist/core/index.js +1780 -0
- package/dist/core/index.js.map +1 -0
- package/dist/react/index.d.ts +90 -0
- package/dist/react/index.js +230 -0
- package/dist/react/index.js.map +1 -0
- package/dist/svelte/index.d.ts +60 -0
- package/dist/svelte/index.js +151 -0
- package/dist/svelte/index.js.map +1 -0
- package/dist/vue/index.d.ts +97 -0
- package/dist/vue/index.js +133 -0
- package/dist/vue/index.js.map +1 -0
- package/package.json +120 -0
|
@@ -0,0 +1,853 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { createRequire } from 'module';
|
|
4
|
+
import { existsSync, mkdirSync, writeFileSync } from 'fs';
|
|
5
|
+
import { resolve, join } from 'path';
|
|
6
|
+
import { spawnSync, execSync } from 'child_process';
|
|
7
|
+
|
|
8
|
+
var colors = {
|
|
9
|
+
green: (s) => `\x1B[32m${s}\x1B[0m`,
|
|
10
|
+
yellow: (s) => `\x1B[33m${s}\x1B[0m`,
|
|
11
|
+
red: (s) => `\x1B[31m${s}\x1B[0m`,
|
|
12
|
+
cyan: (s) => `\x1B[36m${s}\x1B[0m`,
|
|
13
|
+
bold: (s) => `\x1B[1m${s}\x1B[0m`,
|
|
14
|
+
dim: (s) => `\x1B[2m${s}\x1B[0m`
|
|
15
|
+
};
|
|
16
|
+
var log = {
|
|
17
|
+
info: (msg) => console.log(`${colors.cyan("\u2139")} ${msg}`),
|
|
18
|
+
success: (msg) => console.log(`${colors.green("\u2713")} ${msg}`),
|
|
19
|
+
warn: (msg) => console.warn(`${colors.yellow("\u26A0")} ${msg}`),
|
|
20
|
+
error: (msg) => console.error(`${colors.red("\u2717")} ${msg}`),
|
|
21
|
+
bold: (msg) => console.log(colors.bold(msg)),
|
|
22
|
+
dim: (msg) => console.log(colors.dim(msg))
|
|
23
|
+
};
|
|
24
|
+
var CONFIG_CANDIDATES = [
|
|
25
|
+
"offlinekit.config.ts",
|
|
26
|
+
"offlinekit.config.js",
|
|
27
|
+
"offlinekit.schema.ts",
|
|
28
|
+
"offlinekit.schema.js"
|
|
29
|
+
];
|
|
30
|
+
function findSchemaFile(cwd = process.cwd()) {
|
|
31
|
+
for (const candidate of CONFIG_CANDIDATES) {
|
|
32
|
+
const p = resolve(cwd, candidate);
|
|
33
|
+
if (existsSync(p)) return p;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
async function loadSchema(filePath) {
|
|
38
|
+
const mod = await import(filePath);
|
|
39
|
+
return mod.default ?? mod;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// src/cli/commands/dev.ts
|
|
43
|
+
function registerDev(program2) {
|
|
44
|
+
program2.command("dev").description("Start OfflineKit in local-only mode (no cloud sync)").action(() => {
|
|
45
|
+
process.env["OFFLINEKIT_MODE"] = "local";
|
|
46
|
+
log.bold("OfflineKit Dev Mode");
|
|
47
|
+
log.info("Running in local-only mode (no cloud sync)");
|
|
48
|
+
log.dim("All data is stored locally. Sync is disabled.");
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// src/cli/generator/templates/index.ts
|
|
53
|
+
function workerIndexTemplate(_appName) {
|
|
54
|
+
return `
|
|
55
|
+
import { Hono } from 'hono'
|
|
56
|
+
import { cors } from 'hono/cors'
|
|
57
|
+
import { auth } from './routes/auth.js'
|
|
58
|
+
import { sync } from './routes/sync.js'
|
|
59
|
+
import { ws, WsSessions } from './routes/ws.js'
|
|
60
|
+
import { errors } from './routes/errors.js'
|
|
61
|
+
import { health } from './routes/health.js'
|
|
62
|
+
|
|
63
|
+
export interface Env {
|
|
64
|
+
STORAGE: R2Bucket
|
|
65
|
+
KV: KVNamespace
|
|
66
|
+
JWT_SECRET: string
|
|
67
|
+
WS_SESSIONS: DurableObjectNamespace
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export { WsSessions }
|
|
71
|
+
|
|
72
|
+
const app = new Hono<{ Bindings: Env }>()
|
|
73
|
+
|
|
74
|
+
app.use('/*', cors({
|
|
75
|
+
origin: '*',
|
|
76
|
+
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
|
77
|
+
allowHeaders: ['Content-Type', 'Authorization'],
|
|
78
|
+
}))
|
|
79
|
+
|
|
80
|
+
app.route('/auth', auth)
|
|
81
|
+
app.route('/sync', sync)
|
|
82
|
+
app.route('/ws', ws)
|
|
83
|
+
app.route('/errors', errors)
|
|
84
|
+
app.route('/health', health)
|
|
85
|
+
|
|
86
|
+
export default app
|
|
87
|
+
`.trimStart();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// src/cli/generator/templates/auth.ts
|
|
91
|
+
function authTemplate() {
|
|
92
|
+
return `
|
|
93
|
+
import { Hono } from 'hono'
|
|
94
|
+
import { sign, verify } from 'hono/jwt'
|
|
95
|
+
import type { Env } from './index.js'
|
|
96
|
+
import { AUTH_KEY, SESSION_KEY, type StoredUser } from '../storage/kv.js'
|
|
97
|
+
|
|
98
|
+
export const auth = new Hono<{ Bindings: Env }>()
|
|
99
|
+
|
|
100
|
+
/** Constant-time string comparison to prevent timing side-channels */
|
|
101
|
+
function timingSafeEqual(a: string, b: string): boolean {
|
|
102
|
+
if (a.length !== b.length) return false
|
|
103
|
+
const encoder = new TextEncoder()
|
|
104
|
+
const bufA = encoder.encode(a)
|
|
105
|
+
const bufB = encoder.encode(b)
|
|
106
|
+
let result = 0
|
|
107
|
+
for (let i = 0; i < bufA.length; i++) result |= bufA[i] ^ bufB[i]
|
|
108
|
+
return result === 0
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Extract userId from Bearer token \u2014 throws on invalid/expired */
|
|
112
|
+
export async function authMiddleware(c: any, next: () => Promise<void>) {
|
|
113
|
+
const header = c.req.header('Authorization') ?? ''
|
|
114
|
+
const token = header.startsWith('Bearer ') ? header.slice(7) : null
|
|
115
|
+
if (!token) return c.json({ error: 'Unauthorized' }, 401)
|
|
116
|
+
try {
|
|
117
|
+
const payload = await verify(token, c.env.JWT_SECRET) as { sub: string }
|
|
118
|
+
c.set('userId', payload.sub)
|
|
119
|
+
} catch {
|
|
120
|
+
return c.json({ error: 'Invalid token' }, 401)
|
|
121
|
+
}
|
|
122
|
+
return next()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
auth.post('/signup', async (c) => {
|
|
126
|
+
const { email, passwordHash } = await c.req.json<{ email: string; passwordHash: string }>()
|
|
127
|
+
if (!email || !passwordHash) return c.json({ error: 'email and passwordHash required' }, 400)
|
|
128
|
+
|
|
129
|
+
const existing = await c.env.KV.get(AUTH_KEY(email))
|
|
130
|
+
if (existing) return c.json({ error: 'Email already registered' }, 409)
|
|
131
|
+
|
|
132
|
+
const userId = crypto.randomUUID()
|
|
133
|
+
const user: StoredUser = { userId, email, passwordHash }
|
|
134
|
+
await c.env.KV.put(AUTH_KEY(email), JSON.stringify(user))
|
|
135
|
+
|
|
136
|
+
const token = await sign({ sub: userId, email, exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 30 }, c.env.JWT_SECRET)
|
|
137
|
+
return c.json({ user: { id: userId, email }, token }, 201)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
auth.post('/signin', async (c) => {
|
|
141
|
+
const { email, passwordHash } = await c.req.json<{ email: string; passwordHash: string }>()
|
|
142
|
+
if (!email || !passwordHash) return c.json({ error: 'email and passwordHash required' }, 400)
|
|
143
|
+
|
|
144
|
+
const raw = await c.env.KV.get(AUTH_KEY(email))
|
|
145
|
+
if (!raw) return c.json({ error: 'Invalid credentials' }, 401)
|
|
146
|
+
|
|
147
|
+
const user = JSON.parse(raw) as StoredUser
|
|
148
|
+
if (!timingSafeEqual(user.passwordHash, passwordHash)) return c.json({ error: 'Invalid credentials' }, 401)
|
|
149
|
+
|
|
150
|
+
const token = await sign({ sub: user.userId, email, exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 30 }, c.env.JWT_SECRET)
|
|
151
|
+
return c.json({ user: { id: user.userId, email }, token })
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
auth.post('/signout', async (c) => {
|
|
155
|
+
const header = c.req.header('Authorization') ?? ''
|
|
156
|
+
const token = header.startsWith('Bearer ') ? header.slice(7) : null
|
|
157
|
+
if (token) await c.env.KV.delete(SESSION_KEY(token))
|
|
158
|
+
return c.json({ ok: true })
|
|
159
|
+
})
|
|
160
|
+
`.trimStart();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// src/cli/generator/templates/sync.ts
|
|
164
|
+
function syncTemplate() {
|
|
165
|
+
return `
|
|
166
|
+
import { Hono } from 'hono'
|
|
167
|
+
import type { Env } from './index.js'
|
|
168
|
+
import { authMiddleware } from './auth.js'
|
|
169
|
+
import { docKey, collectionPrefix } from '../storage/r2.js'
|
|
170
|
+
|
|
171
|
+
interface SyncDoc {
|
|
172
|
+
_id: string
|
|
173
|
+
_collection: string
|
|
174
|
+
_updatedAt: number
|
|
175
|
+
_deleted: boolean
|
|
176
|
+
[key: string]: unknown
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export const sync = new Hono<{ Bindings: Env }>()
|
|
180
|
+
|
|
181
|
+
sync.use('/*', authMiddleware)
|
|
182
|
+
|
|
183
|
+
sync.post('/push', async (c) => {
|
|
184
|
+
const userId = c.get('userId') as string
|
|
185
|
+
const { changes } = await c.req.json<{ changes: SyncDoc[] }>()
|
|
186
|
+
|
|
187
|
+
await Promise.all(
|
|
188
|
+
changes.map(async (doc) => {
|
|
189
|
+
const key = docKey(userId, doc._collection, doc._id)
|
|
190
|
+
const existing = await c.env.STORAGE.get(key, 'json') as SyncDoc | null
|
|
191
|
+
|
|
192
|
+
// LWW \u2014 only overwrite if incoming is newer
|
|
193
|
+
if (!existing || doc._updatedAt >= existing._updatedAt) {
|
|
194
|
+
await c.env.STORAGE.put(key, JSON.stringify(doc), {
|
|
195
|
+
customMetadata: { updatedAt: String(doc._updatedAt) },
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
}),
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
return c.json({ ok: true, received: changes.length })
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
sync.get('/pull', async (c) => {
|
|
205
|
+
const userId = c.get('userId') as string
|
|
206
|
+
const since = parseInt(c.req.query('since') ?? '0', 10)
|
|
207
|
+
|
|
208
|
+
// Scan all collections for this user \u2014 list all objects under {userId}/
|
|
209
|
+
const prefix = \`\${userId}/\`
|
|
210
|
+
const listed = await c.env.STORAGE.list({ prefix })
|
|
211
|
+
|
|
212
|
+
const changes = await Promise.all(
|
|
213
|
+
listed.objects
|
|
214
|
+
.filter((obj) => {
|
|
215
|
+
const ts = parseInt(obj.customMetadata?.updatedAt ?? '0', 10)
|
|
216
|
+
return ts > since
|
|
217
|
+
})
|
|
218
|
+
.map(async (obj) => {
|
|
219
|
+
const blob = await c.env.STORAGE.get(obj.key, 'json')
|
|
220
|
+
return blob as SyncDoc
|
|
221
|
+
}),
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
return c.json({ changes: changes.filter(Boolean) })
|
|
225
|
+
})
|
|
226
|
+
`.trimStart();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// src/cli/generator/templates/ws.ts
|
|
230
|
+
function wsTemplate() {
|
|
231
|
+
return `import { Hono } from 'hono'
|
|
232
|
+
import { verify } from 'hono/jwt'
|
|
233
|
+
import type { Env } from './index.js'
|
|
234
|
+
import { authMiddleware } from './auth.js'
|
|
235
|
+
import { docKey } from '../storage/r2.js'
|
|
236
|
+
|
|
237
|
+
export const ws = new Hono<{ Bindings: Env }>()
|
|
238
|
+
|
|
239
|
+
// Proxy WS upgrade requests to the user-scoped Durable Object
|
|
240
|
+
ws.get('/', authMiddleware, async (c) => {
|
|
241
|
+
const userId = c.get('userId') as string
|
|
242
|
+
const id = c.env.WS_SESSIONS.idFromName(userId)
|
|
243
|
+
return c.env.WS_SESSIONS.get(id).fetch(c.req.raw)
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
// ---- Durable Object: one instance per userId, owns all WebSocket sessions ----
|
|
247
|
+
|
|
248
|
+
interface SyncDoc {
|
|
249
|
+
_id: string
|
|
250
|
+
_collection: string
|
|
251
|
+
_updatedAt: number
|
|
252
|
+
_deleted: boolean
|
|
253
|
+
[key: string]: unknown
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
interface WsMsg {
|
|
257
|
+
type: string
|
|
258
|
+
id?: string
|
|
259
|
+
payload?: unknown
|
|
260
|
+
since?: number
|
|
261
|
+
token?: string
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export class WsSessions {
|
|
265
|
+
private readonly sessions = new Map<WebSocket, { userId: string | null }>()
|
|
266
|
+
|
|
267
|
+
constructor(
|
|
268
|
+
private readonly state: DurableObjectState,
|
|
269
|
+
private readonly env: Env,
|
|
270
|
+
) {}
|
|
271
|
+
|
|
272
|
+
async fetch(request: Request): Promise<Response> {
|
|
273
|
+
if (request.headers.get('Upgrade') !== 'websocket') {
|
|
274
|
+
return new Response('Expected WebSocket upgrade', { status: 426 })
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const pair = new WebSocketPair()
|
|
278
|
+
const [client, server] = Object.values(pair) as [WebSocket, WebSocket]
|
|
279
|
+
server.accept()
|
|
280
|
+
|
|
281
|
+
const session = { userId: null as string | null }
|
|
282
|
+
this.sessions.set(server, session)
|
|
283
|
+
|
|
284
|
+
server.addEventListener('message', async (event) => {
|
|
285
|
+
let msg: WsMsg
|
|
286
|
+
try { msg = JSON.parse(event.data as string) as WsMsg } catch { return }
|
|
287
|
+
try {
|
|
288
|
+
await this.handle(server, session, msg)
|
|
289
|
+
} catch (err) {
|
|
290
|
+
server.send(JSON.stringify({ type: 'error', id: msg.id, payload: { message: String(err) } }))
|
|
291
|
+
}
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
server.addEventListener('close', () => { this.sessions.delete(server) })
|
|
295
|
+
|
|
296
|
+
return new Response(null, { status: 101, webSocket: client })
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private async handle(ws: WebSocket, session: { userId: string | null }, msg: WsMsg): Promise<void> {
|
|
300
|
+
if (msg.type === 'auth') {
|
|
301
|
+
try {
|
|
302
|
+
const payload = await verify(msg.token ?? '', this.env.JWT_SECRET) as { sub?: string }
|
|
303
|
+
if (!payload.sub) throw new Error('Missing sub')
|
|
304
|
+
session.userId = payload.sub
|
|
305
|
+
ws.send(JSON.stringify({ type: 'auth_ack', id: msg.id }))
|
|
306
|
+
} catch {
|
|
307
|
+
ws.send(JSON.stringify({ type: 'error', id: msg.id, payload: { message: 'Unauthorized' } }))
|
|
308
|
+
ws.close(1008, 'Unauthorized')
|
|
309
|
+
}
|
|
310
|
+
return
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (!session.userId) {
|
|
314
|
+
ws.send(JSON.stringify({ type: 'error', id: msg.id, payload: { message: 'Not authenticated' } }))
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (msg.type === 'push') {
|
|
319
|
+
const { changes } = msg.payload as { changes: SyncDoc[] }
|
|
320
|
+
await Promise.all(changes.map(async (doc) => {
|
|
321
|
+
const key = docKey(session.userId!, doc._collection, doc._id)
|
|
322
|
+
const existing = await this.env.STORAGE.get(key, 'json') as SyncDoc | null
|
|
323
|
+
if (!existing || doc._updatedAt >= existing._updatedAt) {
|
|
324
|
+
await this.env.STORAGE.put(key, JSON.stringify(doc), {
|
|
325
|
+
customMetadata: { updatedAt: String(doc._updatedAt) },
|
|
326
|
+
})
|
|
327
|
+
}
|
|
328
|
+
}))
|
|
329
|
+
ws.send(JSON.stringify({ type: 'push_ack', id: msg.id, payload: { ok: true } }))
|
|
330
|
+
this.broadcast(session.userId, ws, { type: 'remote_changes', changes })
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (msg.type === 'pull') {
|
|
335
|
+
const since = typeof msg.since === 'number' ? msg.since : 0
|
|
336
|
+
const listed = await this.env.STORAGE.list({ prefix: \`\${session.userId}/\` })
|
|
337
|
+
const changes = (await Promise.all(
|
|
338
|
+
listed.objects
|
|
339
|
+
.filter((o) => parseInt(o.customMetadata?.updatedAt ?? '0', 10) > since)
|
|
340
|
+
.map((o) => this.env.STORAGE.get(o.key, 'json') as Promise<SyncDoc>),
|
|
341
|
+
)).filter(Boolean)
|
|
342
|
+
ws.send(JSON.stringify({ type: 'pull_response', id: msg.id, payload: { changes } }))
|
|
343
|
+
return
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
ws.send(JSON.stringify({ type: 'error', id: msg.id, payload: { message: \`Unknown type: \${msg.type}\` } }))
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private broadcast(userId: string, sender: WebSocket, msg: object): void {
|
|
350
|
+
const data = JSON.stringify(msg)
|
|
351
|
+
for (const [ws, s] of this.sessions) {
|
|
352
|
+
if (ws !== sender && s.userId === userId) {
|
|
353
|
+
try { ws.send(data) } catch { /* stale connection */ }
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// JWT verification handled by hono/jwt verify() \u2014 no hand-rolled crypto needed
|
|
360
|
+
`.trimStart();
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// src/cli/generator/templates/errors.ts
|
|
364
|
+
function errorsTemplate() {
|
|
365
|
+
return `
|
|
366
|
+
import { Hono } from 'hono'
|
|
367
|
+
import type { Env } from './index.js'
|
|
368
|
+
import { authMiddleware } from './auth.js'
|
|
369
|
+
|
|
370
|
+
export const errors = new Hono<{ Bindings: Env }>()
|
|
371
|
+
|
|
372
|
+
errors.use('/*', authMiddleware)
|
|
373
|
+
|
|
374
|
+
errors.get('/', async (c) => {
|
|
375
|
+
const userId = c.get('userId') as string
|
|
376
|
+
const prefix = \`\${userId}/_errors/\`
|
|
377
|
+
|
|
378
|
+
const listed = await c.env.STORAGE.list({ prefix })
|
|
379
|
+
const results = await Promise.all(
|
|
380
|
+
listed.objects.map(async (obj) => {
|
|
381
|
+
const blob = await c.env.STORAGE.get(obj.key)
|
|
382
|
+
if (!blob) return null
|
|
383
|
+
return blob.json()
|
|
384
|
+
}),
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
return c.json({ errors: results.filter(Boolean) })
|
|
388
|
+
})
|
|
389
|
+
`.trimStart();
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// src/cli/generator/templates/health.ts
|
|
393
|
+
function healthTemplate() {
|
|
394
|
+
return `
|
|
395
|
+
import { Hono } from 'hono'
|
|
396
|
+
|
|
397
|
+
export const health = new Hono()
|
|
398
|
+
|
|
399
|
+
health.get('/', (c) => {
|
|
400
|
+
return c.json({ status: 'ok', timestamp: Date.now() })
|
|
401
|
+
})
|
|
402
|
+
`.trimStart();
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// src/cli/generator/templates/r2-storage.ts
|
|
406
|
+
function r2StorageTemplate() {
|
|
407
|
+
return `
|
|
408
|
+
export const docKey = (userId: string, collection: string, docId: string) =>
|
|
409
|
+
\`\${userId}/\${collection}/\${docId}.json\`
|
|
410
|
+
|
|
411
|
+
export const collectionPrefix = (userId: string, collection: string) =>
|
|
412
|
+
\`\${userId}/\${collection}/\`
|
|
413
|
+
`.trimStart();
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// src/cli/generator/templates/kv-storage.ts
|
|
417
|
+
function kvStorageTemplate() {
|
|
418
|
+
return `
|
|
419
|
+
/** Key: auth:{email} Value: { userId, email, passwordHash } */
|
|
420
|
+
export const AUTH_KEY = (email: string) => \`auth:\${email}\`
|
|
421
|
+
|
|
422
|
+
/** Key: session:{token} Value: { userId, email, exp } */
|
|
423
|
+
export const SESSION_KEY = (token: string) => \`session:\${token}\`
|
|
424
|
+
|
|
425
|
+
/** Key: meta:{userId} Value: arbitrary metadata object */
|
|
426
|
+
export const META_KEY = (userId: string) => \`meta:\${userId}\`
|
|
427
|
+
|
|
428
|
+
export interface StoredUser {
|
|
429
|
+
userId: string
|
|
430
|
+
email: string
|
|
431
|
+
passwordHash: string
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export interface StoredSession {
|
|
435
|
+
userId: string
|
|
436
|
+
email: string
|
|
437
|
+
exp: number
|
|
438
|
+
}
|
|
439
|
+
`.trimStart();
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// src/cli/generator/templates/wrangler.ts
|
|
443
|
+
function wranglerTemplate(appName) {
|
|
444
|
+
const name = appName.toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
445
|
+
return `name = "${name}-worker"
|
|
446
|
+
main = "src/index.ts"
|
|
447
|
+
compatibility_date = "2024-01-01"
|
|
448
|
+
compatibility_flags = ["nodejs_compat"]
|
|
449
|
+
|
|
450
|
+
[[r2_buckets]]
|
|
451
|
+
binding = "STORAGE"
|
|
452
|
+
bucket_name = "${name}-storage"
|
|
453
|
+
|
|
454
|
+
[[kv_namespaces]]
|
|
455
|
+
binding = "KV"
|
|
456
|
+
id = "REPLACE_WITH_KV_NAMESPACE_ID"
|
|
457
|
+
|
|
458
|
+
[[durable_objects.bindings]]
|
|
459
|
+
name = "WS_SESSIONS"
|
|
460
|
+
class_name = "WsSessions"
|
|
461
|
+
|
|
462
|
+
[[migrations]]
|
|
463
|
+
tag = "v1"
|
|
464
|
+
new_classes = ["WsSessions"]
|
|
465
|
+
|
|
466
|
+
# Security: Set secrets via Cloudflare dashboard or CLI:
|
|
467
|
+
# wrangler secret put JWT_SECRET
|
|
468
|
+
`;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// src/cli/generator/index.ts
|
|
472
|
+
function generateWorker(options) {
|
|
473
|
+
const { appName, outDir } = options;
|
|
474
|
+
const dirs = [
|
|
475
|
+
outDir,
|
|
476
|
+
join(outDir, "src"),
|
|
477
|
+
join(outDir, "src", "routes"),
|
|
478
|
+
join(outDir, "src", "storage")
|
|
479
|
+
];
|
|
480
|
+
for (const dir of dirs) mkdirSync(dir, { recursive: true });
|
|
481
|
+
const files = [
|
|
482
|
+
[join(outDir, "wrangler.toml"), wranglerTemplate(appName)],
|
|
483
|
+
[join(outDir, "src", "index.ts"), workerIndexTemplate()],
|
|
484
|
+
[join(outDir, "src", "routes", "auth.ts"), authTemplate()],
|
|
485
|
+
[join(outDir, "src", "routes", "sync.ts"), syncTemplate()],
|
|
486
|
+
[join(outDir, "src", "routes", "ws.ts"), wsTemplate()],
|
|
487
|
+
[join(outDir, "src", "routes", "errors.ts"), errorsTemplate()],
|
|
488
|
+
[join(outDir, "src", "routes", "health.ts"), healthTemplate()],
|
|
489
|
+
[join(outDir, "src", "storage", "r2.ts"), r2StorageTemplate()],
|
|
490
|
+
[join(outDir, "src", "storage", "kv.ts"), kvStorageTemplate()],
|
|
491
|
+
[join(outDir, "package.json"), workerPackageJson(appName)],
|
|
492
|
+
[join(outDir, "tsconfig.json"), workerTsConfig()]
|
|
493
|
+
];
|
|
494
|
+
for (const [path, content] of files) {
|
|
495
|
+
writeFileSync(path, content, "utf8");
|
|
496
|
+
}
|
|
497
|
+
console.warn(
|
|
498
|
+
'\n\u26A0 Remember to replace placeholder values in wrangler.toml before deploying:\n - JWT_SECRET = "REPLACE_WITH_SECRET" \u2192 set a strong random secret\n - KV namespace id \u2192 set your actual KV namespace ID\n'
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
function workerPackageJson(appName) {
|
|
502
|
+
const name = appName.toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
503
|
+
return JSON.stringify(
|
|
504
|
+
{
|
|
505
|
+
name: `${name}-worker`,
|
|
506
|
+
version: "1.0.0",
|
|
507
|
+
type: "module",
|
|
508
|
+
scripts: {
|
|
509
|
+
dev: "wrangler dev",
|
|
510
|
+
deploy: "wrangler deploy",
|
|
511
|
+
"type-check": "tsc --noEmit"
|
|
512
|
+
},
|
|
513
|
+
dependencies: {
|
|
514
|
+
hono: "^4.6.0"
|
|
515
|
+
},
|
|
516
|
+
devDependencies: {
|
|
517
|
+
"@cloudflare/workers-types": "^4.0.0",
|
|
518
|
+
typescript: "^5.6.0",
|
|
519
|
+
wrangler: "^3.0.0"
|
|
520
|
+
}
|
|
521
|
+
},
|
|
522
|
+
null,
|
|
523
|
+
2
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
function workerTsConfig() {
|
|
527
|
+
return JSON.stringify(
|
|
528
|
+
{
|
|
529
|
+
compilerOptions: {
|
|
530
|
+
target: "ES2022",
|
|
531
|
+
module: "ESNext",
|
|
532
|
+
moduleResolution: "bundler",
|
|
533
|
+
lib: ["ES2022"],
|
|
534
|
+
types: ["@cloudflare/workers-types"],
|
|
535
|
+
strict: true,
|
|
536
|
+
skipLibCheck: true
|
|
537
|
+
},
|
|
538
|
+
include: ["src/**/*"]
|
|
539
|
+
},
|
|
540
|
+
null,
|
|
541
|
+
2
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// src/cli/targets/cloudflare.ts
|
|
546
|
+
var cloudflareTarget = {
|
|
547
|
+
name: "cloudflare",
|
|
548
|
+
generate({ appName, outDir }) {
|
|
549
|
+
generateWorker({ appName, outDir });
|
|
550
|
+
}
|
|
551
|
+
};
|
|
552
|
+
var nodeTarget = {
|
|
553
|
+
name: "node",
|
|
554
|
+
generate({ appName, outDir }) {
|
|
555
|
+
const dirs = [
|
|
556
|
+
outDir,
|
|
557
|
+
join(outDir, "src"),
|
|
558
|
+
join(outDir, "src", "routes"),
|
|
559
|
+
join(outDir, "src", "storage")
|
|
560
|
+
];
|
|
561
|
+
for (const dir of dirs) mkdirSync(dir, { recursive: true });
|
|
562
|
+
const serverEntry = nodeServerEntry(appName);
|
|
563
|
+
const files = [
|
|
564
|
+
[join(outDir, "src", "index.ts"), serverEntry],
|
|
565
|
+
[join(outDir, "src", "routes", "auth.ts"), authTemplate()],
|
|
566
|
+
[join(outDir, "src", "routes", "sync.ts"), syncTemplate()],
|
|
567
|
+
[join(outDir, "src", "routes", "ws.ts"), wsTemplate()],
|
|
568
|
+
[join(outDir, "src", "routes", "errors.ts"), errorsTemplate()],
|
|
569
|
+
[join(outDir, "src", "routes", "health.ts"), healthTemplate()],
|
|
570
|
+
[join(outDir, "src", "storage", "r2.ts"), r2StorageTemplate()],
|
|
571
|
+
[join(outDir, "src", "storage", "kv.ts"), kvStorageTemplate()],
|
|
572
|
+
[join(outDir, "package.json"), nodePackageJson(appName)],
|
|
573
|
+
[join(outDir, "tsconfig.json"), nodeTsConfig()]
|
|
574
|
+
];
|
|
575
|
+
for (const [path, content] of files) {
|
|
576
|
+
writeFileSync(path, content, "utf8");
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
};
|
|
580
|
+
function nodeServerEntry(appName) {
|
|
581
|
+
return `import { Hono } from 'hono'
|
|
582
|
+
import { serve } from '@hono/node-server'
|
|
583
|
+
import { createNodeWebSocket } from '@hono/node-ws'
|
|
584
|
+
import { verify } from 'hono/jwt'
|
|
585
|
+
import { auth } from './routes/auth.js'
|
|
586
|
+
import { sync } from './routes/sync.js'
|
|
587
|
+
import { health } from './routes/health.js'
|
|
588
|
+
|
|
589
|
+
const app = new Hono()
|
|
590
|
+
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app })
|
|
591
|
+
|
|
592
|
+
// In-memory session store for WebSocket connections (mirrors DO sessions map)
|
|
593
|
+
const sessions = new Map<WebSocket, { userId: string }>()
|
|
594
|
+
|
|
595
|
+
app.route('/auth', auth)
|
|
596
|
+
app.route('/sync', sync)
|
|
597
|
+
app.route('/health', health)
|
|
598
|
+
|
|
599
|
+
app.get('/ws', upgradeWebSocket(() => ({
|
|
600
|
+
onOpen(_event, ws) {
|
|
601
|
+
// session added on auth message
|
|
602
|
+
},
|
|
603
|
+
async onMessage(event, ws) {
|
|
604
|
+
let msg: { type: string; [k: string]: unknown }
|
|
605
|
+
try {
|
|
606
|
+
msg = JSON.parse(event.data.toString())
|
|
607
|
+
} catch {
|
|
608
|
+
ws.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' }))
|
|
609
|
+
return
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const session = sessions.get(ws.raw as WebSocket)
|
|
613
|
+
|
|
614
|
+
if (msg.type === 'auth') {
|
|
615
|
+
try {
|
|
616
|
+
const jwtSecret = process.env.JWT_SECRET
|
|
617
|
+
if (!jwtSecret) throw new Error('JWT_SECRET not configured')
|
|
618
|
+
const payload = await verify(msg.token as string, jwtSecret) as { sub: string }
|
|
619
|
+
if (!payload.sub) throw new Error('Missing sub claim')
|
|
620
|
+
sessions.set(ws.raw as WebSocket, { userId: payload.sub })
|
|
621
|
+
ws.send(JSON.stringify({ type: 'auth', ok: true }))
|
|
622
|
+
} catch {
|
|
623
|
+
ws.send(JSON.stringify({ type: 'error', message: 'Invalid token' }))
|
|
624
|
+
ws.close()
|
|
625
|
+
}
|
|
626
|
+
} else if (!session) {
|
|
627
|
+
ws.send(JSON.stringify({ type: 'error', message: 'Not authenticated' }))
|
|
628
|
+
} else if (msg.type === 'push') {
|
|
629
|
+
ws.send(JSON.stringify({ type: 'push', ok: true }))
|
|
630
|
+
} else if (msg.type === 'pull') {
|
|
631
|
+
ws.send(JSON.stringify({ type: 'pull', changes: [] }))
|
|
632
|
+
}
|
|
633
|
+
},
|
|
634
|
+
onClose(_event, ws) {
|
|
635
|
+
sessions.delete(ws.raw as WebSocket)
|
|
636
|
+
},
|
|
637
|
+
})))
|
|
638
|
+
|
|
639
|
+
const port = parseInt(process.env.PORT ?? '3000', 10)
|
|
640
|
+
console.log(\`${appName} server running on http://localhost:\${port}\`)
|
|
641
|
+
|
|
642
|
+
const server = serve({ fetch: app.fetch, port })
|
|
643
|
+
injectWebSocket(server)
|
|
644
|
+
`;
|
|
645
|
+
}
|
|
646
|
+
function nodePackageJson(appName) {
|
|
647
|
+
const name = appName.toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
648
|
+
return JSON.stringify(
|
|
649
|
+
{
|
|
650
|
+
name: `${name}-server`,
|
|
651
|
+
version: "1.0.0",
|
|
652
|
+
type: "module",
|
|
653
|
+
scripts: {
|
|
654
|
+
dev: "tsx watch src/index.ts",
|
|
655
|
+
start: "node dist/index.js",
|
|
656
|
+
build: "tsc"
|
|
657
|
+
},
|
|
658
|
+
dependencies: {
|
|
659
|
+
hono: "^4.6.0",
|
|
660
|
+
"@hono/node-server": "^1.13.0",
|
|
661
|
+
"@hono/node-ws": "^1.0.0"
|
|
662
|
+
},
|
|
663
|
+
devDependencies: {
|
|
664
|
+
typescript: "^5.6.0",
|
|
665
|
+
tsx: "^4.0.0",
|
|
666
|
+
"@types/node": "^22.0.0"
|
|
667
|
+
}
|
|
668
|
+
},
|
|
669
|
+
null,
|
|
670
|
+
2
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
function nodeTsConfig() {
|
|
674
|
+
return JSON.stringify(
|
|
675
|
+
{
|
|
676
|
+
compilerOptions: {
|
|
677
|
+
target: "ES2022",
|
|
678
|
+
module: "ESNext",
|
|
679
|
+
moduleResolution: "bundler",
|
|
680
|
+
lib: ["ES2022"],
|
|
681
|
+
types: ["node"],
|
|
682
|
+
strict: true,
|
|
683
|
+
skipLibCheck: true,
|
|
684
|
+
outDir: "dist"
|
|
685
|
+
},
|
|
686
|
+
include: ["src/**/*"]
|
|
687
|
+
},
|
|
688
|
+
null,
|
|
689
|
+
2
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// src/cli/commands/build.ts
|
|
694
|
+
var targets = { cloudflare: cloudflareTarget, node: nodeTarget };
|
|
695
|
+
function registerBuild(program2) {
|
|
696
|
+
program2.command("build").description("Build the OfflineKit backend bundle").option("-o, --out <dir>", "Output directory", ".offlinekit/worker").option("-n, --name <name>", "App name for the worker", "offlinekit-app").option("-t, --target <target>", "Deploy target: cloudflare or node", "cloudflare").action(async (opts) => {
|
|
697
|
+
log.bold("OfflineKit Build");
|
|
698
|
+
const target = targets[opts.target];
|
|
699
|
+
if (!target) {
|
|
700
|
+
log.error(`Unknown target: ${opts.target}. Valid targets: ${Object.keys(targets).join(", ")}`);
|
|
701
|
+
process.exit(1);
|
|
702
|
+
}
|
|
703
|
+
const schemaFile = findSchemaFile();
|
|
704
|
+
if (!schemaFile) {
|
|
705
|
+
log.error("No schema file found. Create offlinekit.config.ts in your project root.");
|
|
706
|
+
process.exit(1);
|
|
707
|
+
}
|
|
708
|
+
log.info(`Found schema: ${schemaFile}`);
|
|
709
|
+
try {
|
|
710
|
+
await loadSchema(schemaFile);
|
|
711
|
+
log.success("Schema loaded successfully");
|
|
712
|
+
} catch (err) {
|
|
713
|
+
log.error(`Schema load failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
714
|
+
process.exit(1);
|
|
715
|
+
}
|
|
716
|
+
const outDir = resolve(process.cwd(), opts.out);
|
|
717
|
+
log.info(`Generating ${target.name} backend to: ${outDir}`);
|
|
718
|
+
try {
|
|
719
|
+
target.generate({ appName: opts.name, outDir });
|
|
720
|
+
log.success(`Backend generated at ${outDir}`);
|
|
721
|
+
if (opts.target === "node") {
|
|
722
|
+
log.dim(`Run: cd ${opts.out} && npm install && npm run dev`);
|
|
723
|
+
} else {
|
|
724
|
+
log.dim(`Run: cd ${opts.out} && npm install && wrangler deploy`);
|
|
725
|
+
}
|
|
726
|
+
} catch (err) {
|
|
727
|
+
log.error(`Build failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
728
|
+
process.exit(1);
|
|
729
|
+
}
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
function checkWrangler() {
|
|
733
|
+
try {
|
|
734
|
+
const result = execSync("wrangler --version", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
735
|
+
return result.trim();
|
|
736
|
+
} catch {
|
|
737
|
+
return null;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
function registerDeploy(program2) {
|
|
741
|
+
program2.command("deploy").description("Deploy the OfflineKit Worker to Cloudflare via Wrangler").option("-d, --dir <dir>", "Worker output directory", ".offlinekit/worker").option("-e, --env <env>", "Wrangler environment (e.g. production, staging)").option("-t, --target <target>", "Deploy target (default: cloudflare)", "cloudflare").option("--dry-run", "Print the wrangler command without running it").action((opts) => {
|
|
742
|
+
log.bold("OfflineKit Deploy");
|
|
743
|
+
const serverDir = resolve(process.cwd(), "server");
|
|
744
|
+
if (existsSync(serverDir)) {
|
|
745
|
+
log.info("Warning: This project has been ejected (./server/ exists).");
|
|
746
|
+
log.dim("You can deploy the ejected code directly with `cd server && wrangler deploy`.");
|
|
747
|
+
}
|
|
748
|
+
if (opts.target !== "cloudflare") {
|
|
749
|
+
log.error(`Deploy target "${opts.target}" is not supported yet. Only "cloudflare" is deployable.`);
|
|
750
|
+
log.dim("For Node.js targets, run `offlinekit build --target node` then deploy the output manually.");
|
|
751
|
+
process.exit(1);
|
|
752
|
+
}
|
|
753
|
+
const version = checkWrangler();
|
|
754
|
+
if (!version) {
|
|
755
|
+
log.error("wrangler is not installed or not in PATH.");
|
|
756
|
+
log.dim("Install it with: npm install -g wrangler");
|
|
757
|
+
process.exit(1);
|
|
758
|
+
}
|
|
759
|
+
log.success(`wrangler ${version}`);
|
|
760
|
+
const outDir = resolve(process.cwd(), opts.dir);
|
|
761
|
+
if (!existsSync(outDir)) {
|
|
762
|
+
log.error(`Worker directory not found: ${outDir}`);
|
|
763
|
+
log.dim("Run `offlinekit build` first to generate the worker.");
|
|
764
|
+
process.exit(1);
|
|
765
|
+
}
|
|
766
|
+
const tomlPath = resolve(outDir, "wrangler.toml");
|
|
767
|
+
if (!existsSync(tomlPath)) {
|
|
768
|
+
log.error(`wrangler.toml not found in ${outDir}`);
|
|
769
|
+
log.dim("Run `offlinekit build` to regenerate the worker.");
|
|
770
|
+
process.exit(1);
|
|
771
|
+
}
|
|
772
|
+
const args = ["deploy"];
|
|
773
|
+
if (opts.env) args.push("--env", opts.env);
|
|
774
|
+
log.info(`Deploying from: ${outDir}`);
|
|
775
|
+
if (opts.env) log.info(`Environment: ${opts.env}`);
|
|
776
|
+
if (opts.dryRun) {
|
|
777
|
+
log.dim(`[dry-run] wrangler ${args.join(" ")}`);
|
|
778
|
+
log.dim(`[dry-run] cwd: ${outDir}`);
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
const result = spawnSync("wrangler", args, {
|
|
782
|
+
cwd: outDir,
|
|
783
|
+
stdio: "inherit",
|
|
784
|
+
encoding: "utf8"
|
|
785
|
+
});
|
|
786
|
+
if (result.status !== 0) {
|
|
787
|
+
log.error(`wrangler deploy failed (exit ${result.status ?? "unknown"})`);
|
|
788
|
+
process.exit(result.status ?? 1);
|
|
789
|
+
}
|
|
790
|
+
log.success("Deployed successfully!");
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
var targets2 = {
|
|
794
|
+
cloudflare: cloudflareTarget,
|
|
795
|
+
node: nodeTarget
|
|
796
|
+
};
|
|
797
|
+
function registerEject(program2) {
|
|
798
|
+
program2.command("eject").description("Eject generated Worker code into ./server/ for manual customization").option("-o, --out <dir>", "Output directory for ejected code", "server").option("-n, --name <name>", "App name", "offlinekit-app").option("-t, --target <target>", "Deploy target (cloudflare, node)", "cloudflare").action((opts) => {
|
|
799
|
+
log.bold("OfflineKit Eject");
|
|
800
|
+
const target = targets2[opts.target];
|
|
801
|
+
if (!target) {
|
|
802
|
+
log.error(`Unknown target: "${opts.target}"`);
|
|
803
|
+
log.dim(`Available targets: ${Object.keys(targets2).join(", ")}`);
|
|
804
|
+
process.exit(1);
|
|
805
|
+
}
|
|
806
|
+
const outDir = resolve(process.cwd(), opts.out);
|
|
807
|
+
if (existsSync(outDir)) {
|
|
808
|
+
log.error(`Directory already exists: ${outDir}`);
|
|
809
|
+
log.dim("Remove it or choose a different output directory with --out.");
|
|
810
|
+
process.exit(1);
|
|
811
|
+
}
|
|
812
|
+
log.info(`Ejecting ${opts.target} code to: ${outDir}`);
|
|
813
|
+
try {
|
|
814
|
+
target.generate({ appName: opts.name, outDir });
|
|
815
|
+
log.success(`Ejected to ./${opts.out}/`);
|
|
816
|
+
if (opts.target === "node") {
|
|
817
|
+
log.dim("You can now customize and deploy your Node.js server.");
|
|
818
|
+
log.dim("");
|
|
819
|
+
log.dim("Next steps:");
|
|
820
|
+
log.dim(` cd ${opts.out}`);
|
|
821
|
+
log.dim(" npm install");
|
|
822
|
+
log.dim(" npm run dev");
|
|
823
|
+
log.dim("");
|
|
824
|
+
log.dim("To run in production: npm start");
|
|
825
|
+
} else {
|
|
826
|
+
log.dim("You can now customize and deploy with `wrangler deploy`.");
|
|
827
|
+
log.dim("");
|
|
828
|
+
log.dim("Next steps:");
|
|
829
|
+
log.dim(` cd ${opts.out}`);
|
|
830
|
+
log.dim(" npm install");
|
|
831
|
+
log.dim(" wrangler deploy");
|
|
832
|
+
log.dim("");
|
|
833
|
+
log.dim("Note: Running `offlinekit deploy` will warn that code has been ejected.");
|
|
834
|
+
}
|
|
835
|
+
} catch (err) {
|
|
836
|
+
log.error(`Eject failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
837
|
+
process.exit(1);
|
|
838
|
+
}
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// src/cli/index.ts
|
|
843
|
+
var require2 = createRequire(import.meta.url);
|
|
844
|
+
var pkg = require2("../../package.json");
|
|
845
|
+
var program = new Command();
|
|
846
|
+
program.name("offlinekit").description(pkg.description).version(pkg.version, "-v, --version", "Print the current version");
|
|
847
|
+
registerDev(program);
|
|
848
|
+
registerBuild(program);
|
|
849
|
+
registerDeploy(program);
|
|
850
|
+
registerEject(program);
|
|
851
|
+
program.parse(process.argv);
|
|
852
|
+
//# sourceMappingURL=index.js.map
|
|
853
|
+
//# sourceMappingURL=index.js.map
|