@pikku/deploy-standalone 0.12.13 → 0.12.19

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/src/adapter.ts CHANGED
@@ -16,8 +16,23 @@
16
16
  * compiles the bundle into a single self-contained executable via
17
17
  * `bun build --compile`. No runtime needed on the target host.
18
18
  */
19
- import type { EntryGenerationContext, ProviderAdapter } from '@pikku/deploy'
20
- import { nodeBuiltinExternals, SERVER_READY_MARKER } from '@pikku/deploy'
19
+ import type {
20
+ BindingSource,
21
+ ContributorPlatform,
22
+ EntryGenerationContext,
23
+ PlatformServiceContributor,
24
+ ProviderAdapter,
25
+ } from '@pikku/deploy'
26
+ import {
27
+ assertContributorsSupported,
28
+ collectContributorImports,
29
+ collectContributorLines,
30
+ dedupeContributors,
31
+ nodeBuiltinExternals,
32
+ SERVER_READY_MARKER,
33
+ } from '@pikku/deploy'
34
+
35
+ export const STANDALONE_BINDING_SOURCES: readonly BindingSource[] = ['env']
21
36
 
22
37
  export type StandaloneRuntime = 'node' | 'bun'
23
38
 
@@ -49,7 +64,263 @@ const sidecarHandshakeLines = (): string[] => [
49
64
  ` console.log(\`${SERVER_READY_MARKER} on http://\${hostname}:\${server.port}\`)`,
50
65
  ]
51
66
 
52
- const SIDECAR_RUNTIME_IMPORT = `import { watchParentProcess } from '@pikku/deploy-standalone/runtime'`
67
+ /**
68
+ * The runtime helpers the entry imports. The database ones are left out of a
69
+ * build with no database, so the bundle carries no migrator it can never run.
70
+ */
71
+ const runtimeImport = (ctx: EntryGenerationContext): string => {
72
+ const names = ['watchParentProcess', 'parseStandaloneCommand']
73
+ if (ctx.db) names.push('runStandaloneCommand', 'resolveMigrationsDir')
74
+ return `import { ${names.join(', ')} } from '@pikku/deploy-standalone/runtime'`
75
+ }
76
+
77
+ /**
78
+ * Environment variable naming the directory the SQLite file lives in.
79
+ *
80
+ * The database has to outlive a release. A deploy that swaps the artifact
81
+ * directory would take the database with it if the file sat beside the bundle,
82
+ * so the path comes from the environment and points somewhere the operator
83
+ * keeps stable across releases, rather than being derived from the bundle's own
84
+ * location the way the frontend directory is.
85
+ */
86
+ const DATA_DIR_VAR = 'PIKKU_DATA_DIR'
87
+
88
+ /**
89
+ * Full override for the database file, for when it must match a path something
90
+ * else already decided — notably `pikku db migrate`, which has to open the same
91
+ * file this opens or the app runs against an unmigrated database.
92
+ */
93
+ const DATABASE_FILE_VAR = 'PIKKU_DATABASE_FILE'
94
+
95
+ const DEFAULT_DATABASE_FILENAME = 'pikku.db'
96
+
97
+ /**
98
+ * The dialect factory each runtime opens SQLite with. bun cannot use the node
99
+ * one — `bun:sqlite` is a different driver, and the node build reaches for
100
+ * `node:sqlite`, which a compiled bun binary does not carry.
101
+ */
102
+ const SQLITE_FACTORY = {
103
+ node: {
104
+ specifier: '@pikku/kysely-node-sqlite',
105
+ fn: 'createNodeSqliteKysely',
106
+ },
107
+ bun: { specifier: '@pikku/kysely-bun-sqlite', fn: 'createBunSqliteKysely' },
108
+ } as const
109
+
110
+ /**
111
+ * The environment variable a Postgres build reads its connection string from.
112
+ *
113
+ * The same name every other pikku host uses, so an artifact dropped onto a
114
+ * machine that already runs a pikku app needs no new variable.
115
+ */
116
+ const DATABASE_URL_VAR = 'DATABASE_URL'
117
+
118
+ /** Imports the coercion plugin, for an app that generated a map. */
119
+ const coercionImportLines = (coercionImportPath: string): string[] => [
120
+ `import { createCoercionPlugin } from '@pikku/kysely'`,
121
+ `import { coercionMap as __pikkuCoercionMap } from '${coercionImportPath}'`,
122
+ ]
123
+
124
+ /** Imports a database-backed entry needs on top of the common set. */
125
+ const dbImportLines = (
126
+ runtime: 'node' | 'bun',
127
+ db: NonNullable<EntryGenerationContext['db']>
128
+ ): string[] => [
129
+ ...(db.engine === 'sqlite'
130
+ ? [
131
+ `import { ${SQLITE_FACTORY[runtime].fn} } from '${SQLITE_FACTORY[runtime].specifier}'`,
132
+ `import { mkdirSync as __pikkuMkdirSync } from 'node:fs'`,
133
+ ]
134
+ : [`import { PikkuKysely } from '@pikku/kysely-postgres'`]),
135
+ ...(db.coercionImportPath ? coercionImportLines(db.coercionImportPath) : []),
136
+ ]
137
+
138
+ /**
139
+ * Opens the database before services are built, so `createSingletonServices`
140
+ * receives `kysely` exactly as a hosted runtime would hand it over.
141
+ *
142
+ * Creating the directory rather than requiring it is deliberate: the artifact
143
+ * is expected to start on a machine where nothing has run yet, and a missing
144
+ * parent directory is the difference between a first boot that works and one
145
+ * that needs a documented mkdir nobody reads.
146
+ */
147
+ const dbSetupLines = (
148
+ runtime: 'node' | 'bun',
149
+ db: NonNullable<EntryGenerationContext['db']>
150
+ ): string[] => {
151
+ const plugins = db.coercionImportPath
152
+ ? `[createCoercionPlugin({ map: __pikkuCoercionMap })]`
153
+ : `[]`
154
+
155
+ if (db.engine === 'sqlite') {
156
+ return [
157
+ ` const __pikkuDbFile = process.env.${DATABASE_FILE_VAR}`,
158
+ ` ? process.env.${DATABASE_FILE_VAR}`,
159
+ ` : __pikkuJoin(__pikkuRequireDataDir(), '${DEFAULT_DATABASE_FILENAME}')`,
160
+ ` __pikkuMkdirSync(__pikkuDirname(__pikkuDbFile), { recursive: true })`,
161
+ ` const kysely = ${SQLITE_FACTORY[runtime].fn}({`,
162
+ ` filename: __pikkuDbFile,`,
163
+ ` plugins: ${plugins},`,
164
+ ` })`,
165
+ ]
166
+ }
167
+
168
+ return [
169
+ ` const __pikkuDbUrl = process.env.${DATABASE_URL_VAR}`,
170
+ ` if (!__pikkuDbUrl) {`,
171
+ ` throw new Error(`,
172
+ ` 'This build connects to Postgres, so it needs ${DATABASE_URL_VAR} set to the database it should open.'`,
173
+ ` )`,
174
+ ` }`,
175
+ ` const __pikkuPg = new PikkuKysely(logger, __pikkuDbUrl)`,
176
+ ` await __pikkuPg.init()`,
177
+ ...(db.coercionImportPath
178
+ ? [
179
+ ` const kysely = __pikkuPg.kysely.withPlugin(`,
180
+ ` createCoercionPlugin({ map: __pikkuCoercionMap })`,
181
+ ` )`,
182
+ ]
183
+ : [` const kysely = __pikkuPg.kysely`]),
184
+ ]
185
+ }
186
+
187
+ /**
188
+ * Where the migrations sit relative to the running artifact.
189
+ *
190
+ * A node bundle reads them from its own directory. A compiled bun binary has no
191
+ * directory — `import.meta.url` points inside the embedded filesystem — so it
192
+ * resolves them beside the executable, which is where an operator unpacking an
193
+ * artifact puts them.
194
+ */
195
+ const bundleDirExpression = (runtime: 'node' | 'bun'): string =>
196
+ runtime === 'node'
197
+ ? `__pikkuDirname(__pikkuFileURLToPath(import.meta.url))`
198
+ : `__pikkuDirname(process.execPath)`
199
+
200
+ /**
201
+ * The command line, parsed before anything is opened.
202
+ *
203
+ * `version` and `help` have to answer without a database, a config factory or a
204
+ * port, because the machine asking may be one where none of the three work yet
205
+ * — which is exactly when someone runs them.
206
+ */
207
+ const commandParseLines = (ctx: EntryGenerationContext): string[] => [
208
+ `const __pikkuCommand = parseStandaloneCommand(process.argv.slice(2), {`,
209
+ ` version: '${(ctx.version ?? 'unknown').replace(/'/g, "\\'")}',`,
210
+ ` hasDb: ${Boolean(ctx.db)},`,
211
+ ...(ctx.db ? [` engine: '${ctx.db.engine}',`] : []),
212
+ `})`,
213
+ `if (__pikkuCommand.kind === 'exit') process.exit(__pikkuCommand.code)`,
214
+ ]
215
+
216
+ /**
217
+ * Runs a non-serve command against the database the app itself just opened, and
218
+ * stops before a port is bound.
219
+ *
220
+ * Reusing the app's own connection is the point: a command that resolved its
221
+ * own would be free to migrate a different database than the next `serve`
222
+ * reads, and the two would only disagree once in production.
223
+ */
224
+ const commandDispatchLines = (
225
+ runtime: 'node' | 'bun',
226
+ ctx: EntryGenerationContext
227
+ ): string[] => {
228
+ if (!ctx.db) {
229
+ return [` if (__pikkuCommand.kind !== 'serve') process.exit(0)`, ``]
230
+ }
231
+
232
+ const dir = `__pikkuJoin(${bundleDirExpression(runtime)}, 'db', '${ctx.db.engine}')`
233
+ const handle =
234
+ ctx.db.engine === 'sqlite'
235
+ ? `databaseFile: __pikkuDbFile,`
236
+ : `sql: __pikkuPg.sql,`
237
+
238
+ return [
239
+ ` const __pikkuDbCommandTarget = {`,
240
+ ` engine: '${ctx.db.engine}',`,
241
+ ` migrationsDir: resolveMigrationsDir(${dir}),`,
242
+ ` ${handle}`,
243
+ ` }`,
244
+ ` if ((await runStandaloneCommand(__pikkuCommand, __pikkuDbCommandTarget)) === 'done') {`,
245
+ ...(ctx.db.engine === 'postgres' ? [` await __pikkuPg.close()`] : []),
246
+ ` return`,
247
+ ` }`,
248
+ ``,
249
+ ]
250
+ }
251
+
252
+ /** Imports the app's own lifecycle module, when it declares one. */
253
+ const lifecycleImportLines = (
254
+ lifecycle: NonNullable<EntryGenerationContext['lifecycle']>
255
+ ): string[] => [
256
+ `import { ${lifecycle.variable} as __pikkuLifecycle } from '${lifecycle.importPath}'`,
257
+ ]
258
+
259
+ /**
260
+ * Runs the app's start hooks around the port opening, the same order and the
261
+ * same services `pikku dev` gives them.
262
+ *
263
+ * `beforeStart` runs after `init` so a hook can rely on everything the server
264
+ * resolved, and before `start` so work that must finish before the first
265
+ * request — a seeded admin account, a schema probe — is finished when one
266
+ * arrives.
267
+ */
268
+ const lifecycleStartLines = (): string[] => [
269
+ ` await __pikkuLifecycle?.beforeStart?.(singletonServices)`,
270
+ ]
271
+
272
+ const lifecycleAfterStartLines = (): string[] => [
273
+ ` await __pikkuLifecycle?.afterStart?.(singletonServices)`,
274
+ ]
275
+
276
+ /**
277
+ * Hands the stop hooks to the signal handler that owns the shutdown.
278
+ *
279
+ * The connection pool is closed in `afterStop`, once the app's own hook and the
280
+ * server have both finished with it — a pool closed any earlier takes the
281
+ * queries they are still allowed to make down with it. SQLite needs no
282
+ * counterpart: the process exiting releases the file.
283
+ */
284
+ const shutdownHooksArg = (ctx: EntryGenerationContext): string => {
285
+ const before: string[] = []
286
+ const after: string[] = []
287
+
288
+ if (ctx.lifecycle) {
289
+ before.push(`await __pikkuLifecycle?.beforeStop?.(singletonServices)`)
290
+ after.push(`await __pikkuLifecycle?.afterStop?.(singletonServices)`)
291
+ }
292
+ if (ctx.db?.engine === 'postgres') {
293
+ after.push(`await __pikkuPg.close()`)
294
+ }
295
+
296
+ if (before.length === 0 && after.length === 0) return ''
297
+
298
+ const hooks: string[] = []
299
+ if (before.length > 0) {
300
+ hooks.push(`beforeStop: async () => { ${before.join('; ')} }`)
301
+ }
302
+ if (after.length > 0) {
303
+ hooks.push(`afterStop: async () => { ${after.join('; ')} }`)
304
+ }
305
+ return `{ ${hooks.join(', ')} }`
306
+ }
307
+
308
+ /**
309
+ * Fails with the variable's name rather than whatever SQLite says about a path
310
+ * of `undefined/pikku.db`, which is the error an operator would otherwise have
311
+ * to work backwards from.
312
+ */
313
+ const dataDirHelperLines = (): string[] => [
314
+ `function __pikkuRequireDataDir() {`,
315
+ ` const dir = process.env.${DATA_DIR_VAR}`,
316
+ ` if (!dir) {`,
317
+ ` throw new Error(`,
318
+ ` 'This build bundles a SQLite database, so it needs somewhere to keep it. Set ${DATA_DIR_VAR} to a writable directory that survives a release swap, or set ${DATABASE_FILE_VAR} to the database file itself.'`,
319
+ ` )`,
320
+ ` }`,
321
+ ` return dir`,
322
+ `}`,
323
+ ]
53
324
 
54
325
  /**
55
326
  * `rustc -vV`, or nothing when no toolchain is installed. The triple then falls
@@ -84,6 +355,21 @@ export interface StandaloneProviderAdapterOptions {
84
355
  * The window is a webview onto that origin and nothing else is shipped.
85
356
  */
86
357
  desktopUrl?: string
358
+ contributors?: PlatformServiceContributor[]
359
+ }
360
+
361
+ const contributorPlatform = (
362
+ ctx: EntryGenerationContext
363
+ ): ContributorPlatform => {
364
+ const services = ctx.unit.services ?? []
365
+ return {
366
+ serviceNames: services.map((s) => s.sourceServiceName),
367
+ needsQueue: services.some((s) => s.capability === 'queue'),
368
+ needsWorkflow: services.some((s) => s.capability === 'workflow-state'),
369
+ needsAgent: services.some(
370
+ (s) => s.capability === 'ai-storage' || s.capability === 'ai-model'
371
+ ),
372
+ }
87
373
  }
88
374
 
89
375
  export class StandaloneProviderAdapter implements ProviderAdapter {
@@ -95,6 +381,7 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
95
381
  readonly projectDir?: string
96
382
  readonly desktopIdentifier?: string
97
383
  readonly desktopUrl?: string
384
+ readonly contributors: PlatformServiceContributor[]
98
385
 
99
386
  constructor(options: StandaloneProviderAdapterOptions = {}) {
100
387
  this.runtime = options.runtime ?? 'node'
@@ -102,6 +389,48 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
102
389
  this.projectDir = options.projectDir
103
390
  this.desktopIdentifier = options.desktopIdentifier
104
391
  this.desktopUrl = options.desktopUrl
392
+ this.contributors = dedupeContributors(options.contributors)
393
+ assertContributorsSupported(
394
+ this.contributors,
395
+ STANDALONE_BINDING_SOURCES,
396
+ this.name
397
+ )
398
+ }
399
+
400
+ private contributorImportLines(ctx: EntryGenerationContext): string[] {
401
+ if (this.contributors.length === 0) return []
402
+ return collectContributorImports(
403
+ this.contributors,
404
+ contributorPlatform(ctx)
405
+ )
406
+ }
407
+
408
+ private platformServicesBlock(ctx: EntryGenerationContext): string[] {
409
+ if (this.contributors.length === 0) return []
410
+ return [
411
+ `const createPlatformServices = async (env: Record<string, string | undefined>): Promise<${ctx.servicesType}> => {`,
412
+ ` const services: ${ctx.servicesType} = {}`,
413
+ ...collectContributorLines(this.contributors, {
414
+ ctx,
415
+ platform: contributorPlatform(ctx),
416
+ isGateway: false,
417
+ }),
418
+ ` return services`,
419
+ `}`,
420
+ ``,
421
+ ]
422
+ }
423
+
424
+ private platformServicesCallLines(): string[] {
425
+ if (this.contributors.length === 0) return []
426
+ return [
427
+ ` const platformServices = await createPlatformServices(process.env as Record<string, string | undefined>)`,
428
+ ]
429
+ }
430
+
431
+ private platformServicesSpreadLines(): string[] {
432
+ if (this.contributors.length === 0) return []
433
+ return [` ...platformServices,`]
105
434
  }
106
435
 
107
436
  generateEntrySource(ctx: EntryGenerationContext): string {
@@ -122,13 +451,16 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
122
451
  `import { PikkuNodeHTTPServer } from '@pikku/node-http-server'`,
123
452
  `import { DEFAULT_WS_MAX_PAYLOAD, pikkuWebsocketHandler } from '@pikku/ws'`,
124
453
  `import { WebSocketServer } from 'ws'`,
125
- SIDECAR_RUNTIME_IMPORT,
126
- ...(ctx.frontend
454
+ runtimeImport(ctx),
455
+ ...(ctx.frontend || ctx.db
127
456
  ? [
128
457
  `import { dirname as __pikkuDirname, join as __pikkuJoin } from 'node:path'`,
129
458
  `import { fileURLToPath as __pikkuFileURLToPath } from 'node:url'`,
130
459
  ]
131
460
  : []),
461
+ ...(ctx.db ? dbImportLines('node', ctx.db) : []),
462
+ ...(ctx.lifecycle ? lifecycleImportLines(ctx.lifecycle) : []),
463
+ ...this.contributorImportLines(ctx),
132
464
  ``,
133
465
  ctx.configImport,
134
466
  ctx.servicesImport,
@@ -140,8 +472,12 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
140
472
  `const port = parseInt(process.env.PORT || '3000', 10)`,
141
473
  `const hostname = process.env.HOST || '0.0.0.0'`,
142
474
  ``,
475
+ ...commandParseLines(ctx),
476
+ ``,
477
+ ...this.platformServicesBlock(ctx),
143
478
  `async function main() {`,
144
479
  ` const config = await ${ctx.configVar}()`,
480
+ ...this.platformServicesCallLines(),
145
481
  ` const schedulerService = new InMemorySchedulerService()`,
146
482
  ` const queueService = new InMemoryQueueService()`,
147
483
  ` const workflowService = new InMemoryWorkflowService()`,
@@ -149,14 +485,18 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
149
485
  ` const eventHub = new LocalEventHubService()`,
150
486
  ` workflowService.wireQueueWorkers()`,
151
487
  ` wireAgentScorerQueueWorkers()`,
488
+ ...(ctx.db ? dbSetupLines('node', ctx.db) : []),
489
+ ...commandDispatchLines('node', ctx),
152
490
  ` const singletonServices = await ${ctx.servicesVar}(config, {`,
153
491
  ` logger,`,
492
+ ...(ctx.db ? [` kysely,`] : []),
154
493
  ` schedulerService,`,
155
494
  ` queueService,`,
156
495
  ` workflowService,`,
157
496
  ` workflowRunService: workflowService,`,
158
497
  ` triggerService,`,
159
498
  ` eventHub,`,
499
+ ...this.platformServicesSpreadLines(),
160
500
  ` })`,
161
501
  ` pikkuState(null, 'package', 'singletonServices', singletonServices)`,
162
502
  ``,
@@ -185,8 +525,10 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
185
525
  ` await server.init()`,
186
526
  ` await schedulerService.start()`,
187
527
  ` await triggerService.start()`,
188
- ` server.enableExitOnSignals()`,
528
+ ...(ctx.lifecycle ? lifecycleStartLines() : []),
529
+ ` server.enableExitOnSignals(${shutdownHooksArg(ctx)})`,
189
530
  ` await server.start()`,
531
+ ...(ctx.lifecycle ? lifecycleAfterStartLines() : []),
190
532
  ...sidecarHandshakeLines(),
191
533
  `}`,
192
534
  ``,
@@ -195,6 +537,7 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
195
537
  ` process.exit(1)`,
196
538
  `})`,
197
539
  ``,
540
+ ...(ctx.db?.engine === 'sqlite' ? [...dataDirHelperLines(), ``] : []),
198
541
  ].join('\n')
199
542
  }
200
543
 
@@ -202,7 +545,7 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
202
545
  return [
203
546
  `// Generated standalone entry (bun runtime) — all functions in one process`,
204
547
  `import { ConsoleLogger, InMemoryQueueService, InMemoryTriggerService, InMemoryWorkflowService } from '@pikku/core/services'`,
205
- SIDECAR_RUNTIME_IMPORT,
548
+ runtimeImport(ctx),
206
549
  `import { pikkuState } from '@pikku/core/state'`,
207
550
  `import { wireAgentScorerQueueWorkers } from '@pikku/core/agent-scorer'`,
208
551
  `import { InMemorySchedulerService } from '@pikku/schedule'`,
@@ -210,6 +553,14 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
210
553
  ...(ctx.frontend
211
554
  ? [`import { frontendAssets } from '${STANDALONE_FRONTEND_MANIFEST}'`]
212
555
  : []),
556
+ ...(ctx.db
557
+ ? [
558
+ `import { dirname as __pikkuDirname, join as __pikkuJoin } from 'node:path'`,
559
+ ]
560
+ : []),
561
+ ...(ctx.db ? dbImportLines('bun', ctx.db) : []),
562
+ ...(ctx.lifecycle ? lifecycleImportLines(ctx.lifecycle) : []),
563
+ ...this.contributorImportLines(ctx),
213
564
  ``,
214
565
  ctx.configImport,
215
566
  ctx.servicesImport,
@@ -221,8 +572,12 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
221
572
  `const port = parseInt(process.env.PORT || '3000', 10)`,
222
573
  `const hostname = process.env.HOST || '0.0.0.0'`,
223
574
  ``,
575
+ ...commandParseLines(ctx),
576
+ ``,
577
+ ...this.platformServicesBlock(ctx),
224
578
  `async function main() {`,
225
579
  ` const config = await ${ctx.configVar}()`,
580
+ ...this.platformServicesCallLines(),
226
581
  ` const schedulerService = new InMemorySchedulerService()`,
227
582
  ` const queueService = new InMemoryQueueService()`,
228
583
  ` const workflowService = new InMemoryWorkflowService()`,
@@ -230,14 +585,18 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
230
585
  ` const eventHub = new BunEventHubService()`,
231
586
  ` workflowService.wireQueueWorkers()`,
232
587
  ` wireAgentScorerQueueWorkers()`,
588
+ ...(ctx.db ? dbSetupLines('bun', ctx.db) : []),
589
+ ...commandDispatchLines('bun', ctx),
233
590
  ` const singletonServices = await ${ctx.servicesVar}(config, {`,
234
591
  ` logger,`,
592
+ ...(ctx.db ? [` kysely,`] : []),
235
593
  ` schedulerService,`,
236
594
  ` queueService,`,
237
595
  ` workflowService,`,
238
596
  ` workflowRunService: workflowService,`,
239
597
  ` triggerService,`,
240
598
  ` eventHub,`,
599
+ ...this.platformServicesSpreadLines(),
241
600
  ` })`,
242
601
  ` pikkuState(null, 'package', 'singletonServices', singletonServices)`,
243
602
  ``,
@@ -258,8 +617,10 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
258
617
  ` await server.init()`,
259
618
  ` await schedulerService.start()`,
260
619
  ` await triggerService.start()`,
261
- ` server.enableExitOnSignals()`,
620
+ ...(ctx.lifecycle ? lifecycleStartLines() : []),
621
+ ` server.enableExitOnSignals(${shutdownHooksArg(ctx)})`,
262
622
  ` await server.start()`,
623
+ ...(ctx.lifecycle ? lifecycleAfterStartLines() : []),
263
624
  ...sidecarHandshakeLines(),
264
625
  `}`,
265
626
  ``,
@@ -268,6 +629,7 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
268
629
  ` process.exit(1)`,
269
630
  `})`,
270
631
  ``,
632
+ ...(ctx.db?.engine === 'sqlite' ? [...dataDirHelperLines(), ``] : []),
271
633
  ].join('\n')
272
634
  }
273
635
 
@@ -294,6 +656,31 @@ export class StandaloneProviderAdapter implements ProviderAdapter {
294
656
  return externals
295
657
  }
296
658
 
659
+ /**
660
+ * The SQLite driver this runtime cannot load.
661
+ *
662
+ * `loadSqliteRuntime` picks its driver by looking for `globalThis.Bun`, so a
663
+ * node process never runs the bun branch — but esbuild still follows the
664
+ * import, and `bun:sqlite` sits at the top of that module as a static import
665
+ * it cannot resolve. Left in, the bundle fails to build; marked external, it
666
+ * becomes a top-level import node fails to load. Stubbing removes the branch
667
+ * that was already dead.
668
+ */
669
+ getStubModules(): string[] {
670
+ if (this.runtime === 'bun') return []
671
+ return ['sqlite-runtime-bun']
672
+ }
673
+
674
+ /**
675
+ * The bun bundle is not the artifact that runs — `bun build --compile` turns
676
+ * it into the binary — so esbuild must not rename anything bun will rename
677
+ * again. See `getMangleIdentifiers` on the adapter interface for the boot
678
+ * failure the two passes produce together.
679
+ */
680
+ getMangleIdentifiers(): boolean {
681
+ return this.runtime !== 'bun'
682
+ }
683
+
297
684
  getPlatform(): 'node' {
298
685
  return 'node'
299
686
  }
@@ -0,0 +1,96 @@
1
+ import { describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import type { PlatformServiceContributor } from '@pikku/deploy'
4
+
5
+ import { StandaloneProviderAdapter } from './adapter.js'
6
+
7
+ const ctx = {
8
+ unit: {
9
+ name: 'app',
10
+ role: 'function',
11
+ services: [{ capability: 'kv', sourceServiceName: 'browser' }],
12
+ },
13
+ unitDir: '/build/app',
14
+ bootstrapPath: './.pikku/pikku-bootstrap.gen.js',
15
+ configImport: `import { createConfig } from './config.js'`,
16
+ configVar: 'createConfig',
17
+ servicesImport: `import { createSingletonServices } from './services.js'`,
18
+ servicesVar: 'createSingletonServices',
19
+ singletonServicesImport: '',
20
+ servicesType: 'Record<string, unknown>',
21
+ mcpImport: '',
22
+ mcpServerOption: '',
23
+ } as never
24
+
25
+ const kysely: PlatformServiceContributor = {
26
+ name: 'kysely',
27
+ imports: [`import { Kysely } from 'kysely'`],
28
+ emit: () => [` if (env.DATABASE_URL) services.kysely = new Kysely({})`],
29
+ }
30
+
31
+ const browser: PlatformServiceContributor = {
32
+ name: 'browser',
33
+ imports: (platform) =>
34
+ platform.serviceNames.includes('browser')
35
+ ? [`import { Browser } from './browser.js'`]
36
+ : [],
37
+ emit: () => [` services.browser = new Browser(env, logger)`],
38
+ }
39
+
40
+ const queueBinding: PlatformServiceContributor = {
41
+ name: 'queue-binding',
42
+ requires: ['cloudflare'],
43
+ emit: () => [` services.queue = env.QUEUE`],
44
+ }
45
+
46
+ for (const runtime of ['node', 'bun'] as const) {
47
+ describe(`StandaloneProviderAdapter contributors (${runtime})`, () => {
48
+ test('without contributors the entry has no platform services block', () => {
49
+ const source = new StandaloneProviderAdapter({
50
+ runtime,
51
+ }).generateEntrySource(ctx)
52
+
53
+ assert.doesNotMatch(source, /createPlatformServices/)
54
+ assert.doesNotMatch(source, /platformServices/)
55
+ })
56
+
57
+ test('contributor imports, lines and the spread are all emitted', () => {
58
+ const source = new StandaloneProviderAdapter({
59
+ runtime,
60
+ contributors: [kysely, browser],
61
+ }).generateEntrySource(ctx)
62
+
63
+ assert.match(source, /import { Kysely } from 'kysely'/)
64
+ assert.match(source, /import { Browser } from '\.\/browser\.js'/)
65
+ assert.match(
66
+ source,
67
+ /const createPlatformServices = async \(env: Record<string, string \| undefined>\): Promise<Record<string, unknown>> => \{/
68
+ )
69
+ assert.match(
70
+ source,
71
+ /if \(env\.DATABASE_URL\) services\.kysely = new Kysely\(\{\}\)/
72
+ )
73
+ assert.match(source, /services\.browser = new Browser\(env, logger\)/)
74
+ assert.match(
75
+ source,
76
+ /const platformServices = await createPlatformServices\(process\.env as Record<string, string \| undefined>\)/
77
+ )
78
+ assert.match(
79
+ source,
80
+ /eventHub,\n \.\.\.platformServices,\n \}\)/,
81
+ 'contributed services must be spread last so they override the defaults'
82
+ )
83
+ })
84
+
85
+ test('a contributor that needs cloudflare bindings is refused up front', () => {
86
+ assert.throws(
87
+ () =>
88
+ new StandaloneProviderAdapter({
89
+ runtime,
90
+ contributors: [kysely, queueBinding],
91
+ }),
92
+ /standalone adapter only provides env bindings; unsupported contributors: queue-binding \(requires cloudflare\)/
93
+ )
94
+ })
95
+ })
96
+ }
package/src/index.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
 
18
18
  export { StandaloneProviderAdapter }
19
19
  export type { StandaloneProviderAdapterOptions } from './adapter.js'
20
+ export type { PlatformServiceContributor } from '@pikku/deploy'
20
21
 
21
22
  export const createAdapter = (options?: StandaloneProviderAdapterOptions) =>
22
23
  new StandaloneProviderAdapter(options)