create-flowdular 0.5.1 → 0.6.1

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.
Files changed (105) hide show
  1. package/README.md +8 -5
  2. package/agent-template/.agents/skills/agent-tool-design/SKILL.md +1 -1
  3. package/agent-template/.agents/skills/auth-security-review/SKILL.md +1 -1
  4. package/agent-template/.agents/skills/database-adapter/SKILL.md +5 -5
  5. package/agent-template/.agents/skills/database-adapter/references/first-run-and-matrix.md +2 -2
  6. package/agent-template/.agents/skills/deploy-operate/SKILL.md +1 -1
  7. package/agent-template/.agents/skills/migration-authoring/SKILL.md +4 -4
  8. package/agent-template/.agents/skills/module-new/SKILL.md +1 -1
  9. package/agent-template/.agents/skills/spec-approval/SKILL.md +6 -2
  10. package/agent-template/.agents/skills/spec-interview/SKILL.md +2 -2
  11. package/agent-template/.agents/skills/test-hardening/SKILL.md +2 -2
  12. package/agent-template/.agents/skills/workflow-development/SKILL.md +95 -9
  13. package/agent-template/.ai/agents/sandbox/business-manager.md +2 -2
  14. package/agent-template/.ai/blueprints/add-migration/required-files.yaml +1 -1
  15. package/agent-template/.ai/blueprints/new-module/required-files.yaml +1 -1
  16. package/agent-template/.ai/platform-capabilities.md +10 -8
  17. package/agent-template/.ai/policies/capabilities.yaml +28 -12
  18. package/agent-template/.ai/skills/README.md +1 -1
  19. package/agent-template/.ai/skills/agent-tool-design/SKILL.md +1 -1
  20. package/agent-template/.ai/skills/auth-security-review/SKILL.md +1 -1
  21. package/agent-template/.ai/skills/database-adapter/SKILL.md +5 -5
  22. package/agent-template/.ai/skills/database-adapter/references/first-run-and-matrix.md +2 -2
  23. package/agent-template/.ai/skills/deploy-operate/SKILL.md +1 -1
  24. package/agent-template/.ai/skills/migration-authoring/SKILL.md +4 -4
  25. package/agent-template/.ai/skills/module-new/SKILL.md +1 -1
  26. package/agent-template/.ai/skills/spec-approval/SKILL.md +6 -2
  27. package/agent-template/.ai/skills/spec-interview/SKILL.md +2 -2
  28. package/agent-template/.ai/skills/test-hardening/SKILL.md +2 -2
  29. package/agent-template/.ai/skills/workflow-development/SKILL.md +96 -10
  30. package/agent-template/.claude/skills/agent-tool-design/SKILL.md +1 -1
  31. package/agent-template/.claude/skills/auth-security-review/SKILL.md +1 -1
  32. package/agent-template/.claude/skills/database-adapter/SKILL.md +5 -5
  33. package/agent-template/.claude/skills/database-adapter/references/first-run-and-matrix.md +2 -2
  34. package/agent-template/.claude/skills/deploy-operate/SKILL.md +1 -1
  35. package/agent-template/.claude/skills/migration-authoring/SKILL.md +4 -4
  36. package/agent-template/.claude/skills/module-new/SKILL.md +1 -1
  37. package/agent-template/.claude/skills/spec-approval/SKILL.md +6 -2
  38. package/agent-template/.claude/skills/spec-interview/SKILL.md +2 -2
  39. package/agent-template/.claude/skills/test-hardening/SKILL.md +2 -2
  40. package/agent-template/.claude/skills/workflow-development/SKILL.md +95 -9
  41. package/agent-template/docs/adr/0003-module-settings.md +2 -0
  42. package/agent-template/docs/adr/0007-module-owned-agents.md +35 -1
  43. package/agent-template/docs/agent-contract.md +3 -3
  44. package/agent-template/docs/cli-extensions.md +1 -0
  45. package/agent-template/docs/cli.md +40 -3
  46. package/agent-template/docs/configuration.md +64 -9
  47. package/agent-template/docs/database-adapters.md +30 -22
  48. package/agent-template/docs/design-system.md +7 -3
  49. package/agent-template/docs/getting-started.md +29 -32
  50. package/agent-template/docs/module-distribution.md +79 -86
  51. package/agent-template/docs/module-web-surfaces.md +9 -7
  52. package/agent-template/docs/modules.md +6 -2
  53. package/agent-template/docs/sandbox.md +23 -4
  54. package/agent-template/platform/scripts/build.mjs +7 -0
  55. package/dist/bin.js +3 -6
  56. package/package.json +1 -1
  57. package/template/default/.env.example +8 -3
  58. package/template/default/.vercelignore +8 -0
  59. package/template/default/README.md +20 -11
  60. package/template/default/_gitignore +3 -2
  61. package/template/default/infra/README.md +86 -65
  62. package/template/default/infra/docker/.env.example +71 -0
  63. package/template/default/infra/docker/Dockerfile +29 -11
  64. package/template/default/infra/docker/app-entrypoint.mjs +5 -0
  65. package/template/default/infra/docker/compose.yaml +109 -58
  66. package/template/default/infra/docker/database-urls.mjs +28 -0
  67. package/template/default/infra/docker/pitr.sh +177 -0
  68. package/template/default/infra/docker/postgres/10-roles.sh +16 -12
  69. package/template/default/infra/docker/start.mjs +402 -0
  70. package/template/default/infra/kubernetes/database-secret.example.yaml +3 -3
  71. package/template/default/infra/kubernetes/deployment.yaml +5 -0
  72. package/template/default/infra/sdk-module-manifests.mjs +118 -0
  73. package/template/default/infra/vercel/README.md +262 -0
  74. package/template/default/infra/vercel/build.mjs +223 -0
  75. package/template/default/infra/vercel/handler.mjs +100 -0
  76. package/template/default/modules/example/migrations/0001_example_core.up.sql +2 -2
  77. package/template/default/modules/example/module.json +1 -1
  78. package/template/default/modules/example/package.json +3 -3
  79. package/template/default/modules/example/spec/module.yaml +1 -1
  80. package/template/default/modules/example/src/services/migration.ts +2 -2
  81. package/template/default/modules/example/tests/module.test.ts +1 -1
  82. package/template/default/package.json +2 -3
  83. package/template/default/platform/octane.config.ts +290 -156
  84. package/template/default/platform/package.json +5 -5
  85. package/template/default/platform/scripts/build.mjs +56 -0
  86. package/template/default/platform/scripts/dev.mjs +101 -18
  87. package/template/default/platform/src/generated/modules.server.ts +1 -0
  88. package/template/default/platform/src/server/database.ts +24 -0
  89. package/template/default/platform/src/server/lifecycle.ts +325 -0
  90. package/template/default/platform/src/server/runtime-role.ts +33 -0
  91. package/template/default/platform/src/server/setup/access.ts +160 -0
  92. package/template/default/platform/src/server/setup/adapters.ts +554 -0
  93. package/template/default/platform/src/server/setup/environment.ts +154 -0
  94. package/template/default/platform/src/server/setup/gate.ts +84 -0
  95. package/template/default/platform/src/server/setup/index.ts +181 -0
  96. package/template/default/platform/src/server/setup/modules.ts +119 -0
  97. package/template/default/platform/src/server/setup/page.ts +548 -0
  98. package/template/default/platform/src/server/setup/routes.ts +788 -0
  99. package/template/default/platform/src/server/setup/sanitize.ts +111 -0
  100. package/template/default/platform/src/server/setup/seed.ts +192 -0
  101. package/template/default/platform/src/server/setup/token.ts +79 -0
  102. package/template/default/platform/src/server/worker-tick.ts +193 -0
  103. package/template/default/platform/src/server/workspace-root.ts +16 -0
  104. package/template/default/render.yaml +70 -0
  105. package/template/default/vercel.json +5 -0
@@ -1,5 +1,7 @@
1
+ import { createServer as createHttpServer } from 'node:http';
1
2
  import { resolve } from 'node:path';
2
3
  import { fileURLToPath } from 'node:url';
4
+ import { spawnSync } from 'node:child_process';
3
5
  import { createServer } from 'vite';
4
6
  import {
5
7
  createOctaneLogger,
@@ -8,6 +10,12 @@ import {
8
10
  createTheme,
9
11
  printReady,
10
12
  } from '@flowdular/sdk/dev-console';
13
+ import {
14
+ PLATFORM_SHUTDOWN_BUDGET_MS,
15
+ retirePlatformRuntimes,
16
+ stopOnSignals,
17
+ stopServing,
18
+ } from '@flowdular/sdk/dev-console/shutdown';
11
19
 
12
20
  const appRoot = resolve(fileURLToPath(new URL('..', import.meta.url)));
13
21
  /* `pnpm dev -- --port 4396 --host 0.0.0.0` overrides vite.config.ts, so a
@@ -18,6 +26,7 @@ const flag = (name) => {
18
26
  };
19
27
  const port = Number(flag('--port'));
20
28
  const host = flag('--host');
29
+ if (Number.isInteger(port) && port > 0) process.env.PORT = String(port);
21
30
  const verbose =
22
31
  process.argv.includes('--verbose') ||
23
32
  process.argv.includes('-v') ||
@@ -25,42 +34,116 @@ const verbose =
25
34
  const color = shouldUseColor();
26
35
  const restoreConsole = installOctaneConsoleBridge(verbose, color);
27
36
 
37
+ /* Vite serves in middleware mode behind this server. A Vite that listens
38
+ itself exits the process on SIGTERM as soon as it has closed, before
39
+ octane.config.ts has released its databases. */
28
40
  let server;
41
+ const httpServer = createHttpServer((request, response) =>
42
+ server.middlewares(request, response),
43
+ );
44
+ let localUrl;
29
45
  try {
30
46
  server = await createServer({
31
47
  root: appRoot,
32
48
  configFile: resolve(appRoot, 'vite.config.ts'),
33
49
  customLogger: createOctaneLogger(verbose, color),
34
50
  clearScreen: false,
35
- ...(Number.isInteger(port) && port > 0
36
- ? { server: { port, strictPort: true, ...(host ? { host } : {}) } }
37
- : host
38
- ? { server: { host } }
39
- : {}),
51
+ server: {
52
+ middlewareMode: true,
53
+ ws: { server: httpServer },
54
+ ...(Number.isInteger(port) && port > 0 ? { port, strictPort: true } : {}),
55
+ ...(host ? { host } : {}),
56
+ },
57
+ });
58
+ /* The address Vite would have bound: vite.config.ts or the flags above,
59
+ and localhost when neither names a host. */
60
+ const listenPort = server.config.server.port;
61
+ const configuredHost = server.config.server.host;
62
+ const listenHost =
63
+ configuredHost === true ? undefined : configuredHost || 'localhost';
64
+ await new Promise((resolveListen, rejectListen) => {
65
+ httpServer.once('error', rejectListen);
66
+ httpServer.listen(listenPort, listenHost, () => {
67
+ httpServer.off('error', rejectListen);
68
+ resolveListen();
69
+ });
40
70
  });
41
- await server.listen();
71
+ const displayHost =
72
+ listenHost === undefined || listenHost === '0.0.0.0' || listenHost === '::'
73
+ ? 'localhost'
74
+ : listenHost;
75
+ localUrl = `http://${displayHost.includes(':') ? `[${displayHost}]` : displayHost}:${listenPort}/`;
42
76
  } catch (error) {
77
+ if (httpServer.listening) httpServer.close();
43
78
  restoreConsole();
44
79
  throw error;
45
80
  }
81
+
82
+ /* The process ends on its own once every runtime generation has released
83
+ what it holds, so one that was still preparing when the stop arrived
84
+ drains as well; the deadline bounds the wait. */
85
+ async function stop() {
86
+ process.env.FD_INTERNAL_PLATFORM_TERMINATING = 'true';
87
+ try {
88
+ await Promise.all([
89
+ stopServing(httpServer, server),
90
+ retirePlatformRuntimes(),
91
+ ]);
92
+ } finally {
93
+ await server.close();
94
+ restoreConsole();
95
+ }
96
+ }
97
+ stopOnSignals(
98
+ () =>
99
+ void stop().catch((error) => {
100
+ console.error(error instanceof Error ? error.message : String(error));
101
+ process.exitCode = 1;
102
+ }),
103
+ { deadlineMs: PLATFORM_SHUTDOWN_BUDGET_MS },
104
+ );
105
+
46
106
  printReady({
47
107
  title: 'FLOWDULAR',
48
108
  subtitle: 'development workspace',
49
109
  theme: createTheme(color),
50
110
  lines: [
51
- [
52
- 'local',
53
- server.resolvedUrls?.local?.[0] ??
54
- server.resolvedUrls?.network?.[0] ??
55
- 'the address vite.config.ts sets',
56
- 'info',
57
- ],
111
+ ['local', localUrl, 'info'],
58
112
  ['diagnostics', verbose ? 'verbose' : 'quiet · use --verbose', 'muted'],
59
113
  ],
60
114
  });
61
115
 
62
- /* Closing without exiting lets octane.config.ts release the database on the
63
- same signal; the process ends once both have drained. */
64
- const stop = () => void server.close().finally(restoreConsole);
65
- process.once('SIGINT', stop);
66
- process.once('SIGTERM', stop);
116
+ function openBrowser(url) {
117
+ if (
118
+ process.platform === 'linux' &&
119
+ !process.env.DISPLAY &&
120
+ !process.env.WAYLAND_DISPLAY
121
+ )
122
+ return false;
123
+ const command =
124
+ process.platform === 'darwin'
125
+ ? 'open'
126
+ : process.platform === 'win32'
127
+ ? 'rundll32.exe'
128
+ : 'xdg-open';
129
+ const args =
130
+ process.platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url];
131
+ const result = spawnSync(command, args, { stdio: 'ignore', timeout: 10000 });
132
+ return !result.error && result.status === 0;
133
+ }
134
+
135
+ if (!process.argv.includes('--no-open')) {
136
+ const setupUrl = new URL('/setup', localUrl).href;
137
+ try {
138
+ const response = await fetch(setupUrl, {
139
+ signal: AbortSignal.timeout(30000),
140
+ });
141
+ if (response.ok && (await response.text()).includes('setup-page')) {
142
+ console.log(`First-run setup: ${setupUrl}`);
143
+ if (!openBrowser(setupUrl))
144
+ console.log('Open the setup URL in a browser on this workstation.');
145
+ }
146
+ } catch {
147
+ // The server remains usable when a browser is unavailable.
148
+ }
149
+ }
@@ -121,6 +121,7 @@ export function composeModuleServer(
121
121
  'agents.run-queue',
122
122
  'agents.run-execution.v2',
123
123
  'agents.actions.v1',
124
+ 'agents.actions.v2',
124
125
  'agents.decisions.v1',
125
126
  ],
126
127
  requires: [
@@ -7,8 +7,11 @@ import {
7
7
  } from '@flowdular/sdk/database';
8
8
  import { createPgliteCluster } from '@flowdular/sdk/database-pglite';
9
9
  import { Pool } from 'pg';
10
+ import process from 'node:process';
11
+ import { resolve } from 'node:path';
10
12
 
11
13
  export { databaseProviderConfigFromEnvironment };
14
+ export type PlatformDatabaseProvider = ConfiguredDatabaseProvider;
12
15
 
13
16
  /* The deployable owns both PostgreSQL drivers: node-postgres for a deployment
14
17
  and the embedded build for a local run. @flowdular/sdk/database stays driver free,
@@ -23,3 +26,24 @@ export function createPlatformDatabaseProvider(
23
26
  ...factories,
24
27
  });
25
28
  }
29
+
30
+ /** A missing PostgreSQL URL is an unconfigured installation. */
31
+ export function platformDatabaseConfigured(
32
+ environment: NodeJS.ProcessEnv,
33
+ ): boolean {
34
+ const adapter =
35
+ environment.FD_DATABASE_ADAPTER?.trim() ||
36
+ (environment.NODE_ENV === 'production' ? 'postgresql' : 'pglite');
37
+ return (
38
+ adapter !== 'postgresql' || Boolean(environment.FD_DATABASE_URL?.trim())
39
+ );
40
+ }
41
+
42
+ /** An orchestrator's environment takes precedence over the generated .env. */
43
+ export function loadPlatformEnvironmentFile(workspaceRoot: string): void {
44
+ try {
45
+ process.loadEnvFile(resolve(workspaceRoot, '.env'));
46
+ } catch {
47
+ // Container deployments configure the process without a workspace .env.
48
+ }
49
+ }
@@ -0,0 +1,325 @@
1
+ import process from 'node:process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import type { EventEmitter } from 'node:events';
4
+ import { BroadcastChannel } from 'node:worker_threads';
5
+ import type { Middleware } from '@octanejs/app-core';
6
+ import { serverLogger, trackResponseBody } from '@flowdular/sdk/server';
7
+
8
+ export const PLATFORM_LIFECYCLE_SYMBOL = Symbol.for(
9
+ 'flowdular.platform.runtime-lifecycle',
10
+ );
11
+ export const PLATFORM_LIFECYCLE_ACTIVATE_EVENT =
12
+ 'flowdular:platform-runtime-activate';
13
+ export const PLATFORM_LIFECYCLE_RETIRE_EVENT =
14
+ 'flowdular:platform-runtime-retire';
15
+ const PLATFORM_LIFECYCLE_CHANNEL = 'flowdular.platform.runtime-lifecycle';
16
+
17
+ type Dispose = () => void | Promise<void>;
18
+
19
+ export interface PlatformRuntimeLifecycle {
20
+ readonly middleware: Middleware;
21
+ /** Runs as soon as retirement begins, before the requests in flight drain:
22
+ for a producer that holds a request open until it is told to stop. */
23
+ addInterrupt(interrupt: Dispose): void;
24
+ addQuiesce(quiesce: Dispose): void;
25
+ add(dispose: Dispose): void;
26
+ retire(): Promise<void>;
27
+ }
28
+
29
+ type ProcessWithLifecycle = NodeJS.Process & {
30
+ [PLATFORM_LIFECYCLE_SYMBOL]?: PlatformRuntimeLifecycle;
31
+ };
32
+
33
+ /* An event stream never finishes on its own. Its protocol has the client
34
+ reconnect and resume from Last-Event-ID, which reaches the next generation,
35
+ so retirement ends it instead of waiting for it. Any other body drains. */
36
+ function isEventStream(response: Response): boolean {
37
+ const type = response.headers.get('content-type')?.split(';', 1)[0];
38
+ return type?.trim().toLowerCase() === 'text/event-stream';
39
+ }
40
+
41
+ /* EventSource stops reconnecting for good on any answer but a 200 event
42
+ stream, and a production server keeps listening while it retires. A stream
43
+ request to a retired generation gets an empty stream that ends at once, so
44
+ the client keeps reconnecting until the next process answers. */
45
+ function retiredResponse(request: Request | undefined): Response {
46
+ if (
47
+ request?.method === 'GET' &&
48
+ request.headers.get('accept')?.includes('text/event-stream')
49
+ ) {
50
+ return new Response('retry: 1000\n\n', {
51
+ headers: {
52
+ 'content-type': 'text/event-stream; charset=utf-8',
53
+ 'cache-control': 'no-store',
54
+ },
55
+ });
56
+ }
57
+ return new Response(null, {
58
+ status: 503,
59
+ headers: { 'retry-after': '1' },
60
+ });
61
+ }
62
+
63
+ /* Vite evaluates octane.config.ts again when one of its SSR dependencies is
64
+ invalidated. A generation owns every resource created by that evaluation.
65
+ Retirement interrupts what would hold a request open, waits for requests
66
+ already using the old route closures, then disposes its resources in
67
+ reverse composition order. */
68
+ export function createPlatformRuntimeLifecycle(): PlatformRuntimeLifecycle {
69
+ const disposers: Dispose[] = [];
70
+ const quiescers: Dispose[] = [];
71
+ const interrupts = new Set<Dispose>();
72
+ let interrupted: Promise<unknown[]> = Promise.resolve([]);
73
+ let activeRequests = 0;
74
+ let retired = false;
75
+ let finishing = false;
76
+ let disposal: Promise<void> | undefined;
77
+ let resolveDisposal: (() => void) | undefined;
78
+ let rejectDisposal: ((error: unknown) => void) | undefined;
79
+
80
+ const finish = () => {
81
+ if (!retired || activeRequests !== 0 || disposal === undefined || finishing)
82
+ return;
83
+ finishing = true;
84
+ const currentQuiescers = quiescers.splice(0).reverse();
85
+ const current = disposers.splice(0).reverse();
86
+ void (async () => {
87
+ const failures = await interrupted;
88
+ /* Every background producer stops before any module repository closes. */
89
+ for (const quiesce of currentQuiescers) {
90
+ try {
91
+ await quiesce();
92
+ } catch (error) {
93
+ failures.push(error);
94
+ }
95
+ }
96
+ for (const dispose of current) {
97
+ try {
98
+ await dispose();
99
+ } catch (error) {
100
+ failures.push(error);
101
+ }
102
+ }
103
+ if (failures.length > 0) {
104
+ /* A logger reads the message, not the errors array, so each cause
105
+ is named there rather than left for a debugger. */
106
+ rejectDisposal?.(
107
+ new AggregateError(
108
+ failures,
109
+ 'Platform runtime teardown did not release every resource: ' +
110
+ failures
111
+ .map((failure) =>
112
+ failure instanceof Error ? failure.message : String(failure),
113
+ )
114
+ .join('; '),
115
+ ),
116
+ );
117
+ } else {
118
+ resolveDisposal?.();
119
+ }
120
+ })();
121
+ };
122
+
123
+ const interrupt = async (): Promise<unknown[]> => {
124
+ const failures: unknown[] = [];
125
+ await Promise.all(
126
+ [...interrupts].map(async (run) => {
127
+ try {
128
+ await run();
129
+ } catch (error) {
130
+ failures.push(error);
131
+ }
132
+ }),
133
+ );
134
+ return failures;
135
+ };
136
+
137
+ const lifecycle: PlatformRuntimeLifecycle = {
138
+ middleware: async (context, next) => {
139
+ if (retired) return retiredResponse(context.request);
140
+ activeRequests += 1;
141
+ let endStream: (() => void) | undefined;
142
+ const release = () => {
143
+ if (endStream) interrupts.delete(endStream);
144
+ activeRequests -= 1;
145
+ finish();
146
+ };
147
+ try {
148
+ const response = await next();
149
+ const signal = context.request?.signal;
150
+ if (!isEventStream(response)) {
151
+ return trackResponseBody(response, release, signal);
152
+ }
153
+ const end = new AbortController();
154
+ endStream = () => end.abort(new Error('The platform runtime retired.'));
155
+ if (retired) endStream();
156
+ else interrupts.add(endStream);
157
+ return trackResponseBody(
158
+ response,
159
+ release,
160
+ signal ? AbortSignal.any([signal, end.signal]) : end.signal,
161
+ );
162
+ } catch (error) {
163
+ release();
164
+ throw error;
165
+ }
166
+ },
167
+ addInterrupt(run) {
168
+ if (retired) {
169
+ void Promise.resolve()
170
+ .then(run)
171
+ .catch((error: unknown) => {
172
+ serverLogger().error('late platform interrupt failed', {
173
+ module: 'platform',
174
+ err: error,
175
+ });
176
+ });
177
+ return;
178
+ }
179
+ interrupts.add(run);
180
+ },
181
+ addQuiesce(quiesce) {
182
+ if (retired) {
183
+ void Promise.resolve()
184
+ .then(quiesce)
185
+ .catch((error: unknown) => {
186
+ serverLogger().error('late platform quiesce failed', {
187
+ module: 'platform',
188
+ err: error,
189
+ });
190
+ });
191
+ return;
192
+ }
193
+ quiescers.push(quiesce);
194
+ },
195
+ add(dispose) {
196
+ if (retired) {
197
+ void Promise.resolve()
198
+ .then(dispose)
199
+ .catch((error: unknown) => {
200
+ serverLogger().error('late platform teardown failed', {
201
+ module: 'platform',
202
+ err: error,
203
+ });
204
+ });
205
+ return;
206
+ }
207
+ disposers.push(dispose);
208
+ },
209
+ retire() {
210
+ if (!disposal) {
211
+ disposal = new Promise<void>((resolve, reject) => {
212
+ resolveDisposal = resolve;
213
+ rejectDisposal = reject;
214
+ });
215
+ retired = true;
216
+ interrupted = interrupt();
217
+ finish();
218
+ }
219
+ return disposal;
220
+ },
221
+ };
222
+ return lifecycle;
223
+ }
224
+
225
+ /* Activation happens only after the new configuration composed successfully.
226
+ A failed HMR evaluation therefore tears down only its partial generation and
227
+ leaves the previous, still-routable generation alive. */
228
+ export function activatePlatformRuntimeLifecycle(
229
+ lifecycle: PlatformRuntimeLifecycle,
230
+ ): Promise<void> {
231
+ const owner = process as ProcessWithLifecycle;
232
+ const events = process as unknown as EventEmitter;
233
+ const generationId = `${process.pid}:${randomUUID()}`;
234
+ const channel = new BroadcastChannel(PLATFORM_LIFECYCLE_CHANNEL);
235
+ channel.unref();
236
+ const previous = owner[PLATFORM_LIFECYCLE_SYMBOL];
237
+ owner[PLATFORM_LIFECYCLE_SYMBOL] = lifecycle;
238
+ /* Octane loads its config once through the Vite config loader and again
239
+ through the SSR module runner. Their process wrappers do not share custom
240
+ properties, but both delegate EventEmitter operations to the real process.
241
+ The event is therefore the cross-runner ownership handoff. */
242
+ const onActivation = (
243
+ next: PlatformRuntimeLifecycle,
244
+ report?: (retirement: Promise<void>) => void,
245
+ ) => {
246
+ if (next === lifecycle) return;
247
+ const retirement = lifecycle.retire();
248
+ report?.(retirement);
249
+ void retirement.catch((error: unknown) => {
250
+ serverLogger().error('stale platform teardown failed', {
251
+ module: 'platform',
252
+ err: error,
253
+ });
254
+ });
255
+ };
256
+ const onRetire = (report: (retirement: Promise<void>) => void) => {
257
+ report(lifecycle.retire());
258
+ };
259
+ channel.onmessage = (event) => {
260
+ if (!event.data || typeof event.data !== 'object') return;
261
+ const message = event.data as { type?: unknown; generationId?: unknown };
262
+ if (
263
+ message.type !== 'retire-all' &&
264
+ (message.type !== 'activate' || message.generationId === generationId)
265
+ ) {
266
+ return;
267
+ }
268
+ /* Closing a BroadcastChannel from inside its own callback can wait for the
269
+ callback to return. Start retirement in the next microtask so channel
270
+ teardown cannot deadlock the generation it is releasing. */
271
+ queueMicrotask(() => {
272
+ void lifecycle.retire().catch((error: unknown) => {
273
+ serverLogger().error('cross-runner platform teardown failed', {
274
+ module: 'platform',
275
+ err: error,
276
+ });
277
+ });
278
+ });
279
+ };
280
+ events.on(PLATFORM_LIFECYCLE_ACTIVATE_EVENT, onActivation);
281
+ events.on(PLATFORM_LIFECYCLE_RETIRE_EVENT, onRetire);
282
+ lifecycle.add(() => {
283
+ channel.close();
284
+ events.off(PLATFORM_LIFECYCLE_ACTIVATE_EVENT, onActivation);
285
+ events.off(PLATFORM_LIFECYCLE_RETIRE_EVENT, onRetire);
286
+ if (owner[PLATFORM_LIFECYCLE_SYMBOL] === lifecycle) {
287
+ delete owner[PLATFORM_LIFECYCLE_SYMBOL];
288
+ }
289
+ });
290
+ const retirements = new Set<Promise<void>>();
291
+ events.emit(
292
+ PLATFORM_LIFECYCLE_ACTIVATE_EVENT,
293
+ lifecycle,
294
+ (retirement: Promise<void>) => retirements.add(retirement),
295
+ );
296
+ channel.postMessage({ type: 'activate', generationId });
297
+ if (previous && previous !== lifecycle) {
298
+ const retirement = previous.retire();
299
+ retirements.add(retirement);
300
+ void retirement.catch((error: unknown) => {
301
+ serverLogger().error('stale platform teardown failed', {
302
+ module: 'platform',
303
+ err: error,
304
+ });
305
+ });
306
+ }
307
+ return Promise.all(retirements).then(() => undefined);
308
+ }
309
+
310
+ /* Preparation may inspect durable state but must not create write handles,
311
+ workers or timers. Only a fully prepared generation may retire the one that
312
+ is currently serving requests. */
313
+ export async function prepareAndActivatePlatformRuntimeLifecycle(
314
+ lifecycle: PlatformRuntimeLifecycle,
315
+ preparations: readonly (() => void | Promise<void>)[],
316
+ ): Promise<void> {
317
+ for (const prepare of preparations) await prepare();
318
+ await activatePlatformRuntimeLifecycle(lifecycle);
319
+ }
320
+
321
+ export function activePlatformRuntimeLifecycle():
322
+ | PlatformRuntimeLifecycle
323
+ | undefined {
324
+ return (process as ProcessWithLifecycle)[PLATFORM_LIFECYCLE_SYMBOL];
325
+ }
@@ -0,0 +1,33 @@
1
+ import type { ModuleServerComposition } from '@flowdular/sdk/server';
2
+
3
+ /* combined serves HTTP and runs every module worker in one process. web serves
4
+ HTTP only: its compositions start, but no module worker loop, poller or
5
+ wake-driven claim runs there, so a host that freezes idle instances never
6
+ strands work. The loops a web process still runs are auth.core's expired
7
+ session sweep (auth.core.sessions) and settings log sweep
8
+ (auth.core.settings-log), which start with the auth service in every role;
9
+ freezing them only delays deleting rows no lookup needs and writing a
10
+ platform settings event its saving process deferred. tick runs the module
11
+ workers only inside an authenticated tick request, which drains them before
12
+ it answers. */
13
+ export type PlatformRuntimeRole = 'combined' | 'web' | 'tick';
14
+
15
+ export function platformRuntimeRole(
16
+ environment: NodeJS.ProcessEnv,
17
+ ): PlatformRuntimeRole {
18
+ const role = environment.FD_RUNTIME_ROLE?.trim() || 'combined';
19
+ if (role !== 'combined' && role !== 'web' && role !== 'tick') {
20
+ throw new Error('FD_RUNTIME_ROLE must be "combined", "web" or "tick".');
21
+ }
22
+ return role;
23
+ }
24
+
25
+ /* Awaited in composition order, so a failed worker start fails the boot before
26
+ the process reports itself ready. */
27
+ export async function startModuleWorkers(
28
+ compositions: readonly ModuleServerComposition[],
29
+ role: PlatformRuntimeRole,
30
+ ): Promise<void> {
31
+ if (role !== 'combined') return;
32
+ for (const composition of compositions) await composition.startWorker?.();
33
+ }