@webstir-io/webstir 0.1.3 → 0.1.4

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.
@@ -313,15 +313,6 @@ function ensureBackdrop(root: HTMLElement): void {
313
313
  return;
314
314
  }
315
315
 
316
- const legacy = root.querySelector<HTMLElement>('.webstir-search__backdrop');
317
- if (legacy) {
318
- legacy.classList.add('ws-drawer-backdrop');
319
- legacy.classList.remove('webstir-search__backdrop');
320
- legacy.setAttribute('data-webstir-search-close', '');
321
- legacy.setAttribute('aria-hidden', 'true');
322
- return;
323
- }
324
-
325
316
  const created = document.createElement('div');
326
317
  created.className = 'ws-drawer-backdrop';
327
318
  created.setAttribute('data-webstir-search-close', '');
@@ -1,4 +1,6 @@
1
1
  import crypto from 'node:crypto';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
2
4
 
3
5
  import {
4
6
  createDefaultBunBackendBootstrap,
@@ -54,19 +56,8 @@ export async function start(): Promise<void> {
54
56
  );
55
57
  }
56
58
 
57
- const isMain = (() => {
58
- try {
59
- const argv1 = process.argv?.[1];
60
- if (!argv1) return false;
61
- const here = new URL(import.meta.url);
62
- const run = new URL(`file://${argv1}`);
63
- return here.pathname === run.pathname;
64
- } catch {
65
- return false;
66
- }
67
- })();
68
-
69
- if (isMain) {
59
+ const entrypointPath = process.argv[1];
60
+ if (entrypointPath && path.resolve(entrypointPath) === fileURLToPath(import.meta.url)) {
70
61
  start().catch((err) => {
71
62
  console.error(err);
72
63
  process.exitCode = 1;
@@ -1,4 +1,6 @@
1
1
  import crypto from 'node:crypto';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
2
4
 
3
5
  import {
4
6
  createDefaultBunBackendBootstrap,
@@ -54,19 +56,8 @@ export async function start(): Promise<void> {
54
56
  );
55
57
  }
56
58
 
57
- const isMain = (() => {
58
- try {
59
- const argv1 = process.argv?.[1];
60
- if (!argv1) return false;
61
- const here = new URL(import.meta.url);
62
- const run = new URL(`file://${argv1}`);
63
- return here.pathname === run.pathname;
64
- } catch {
65
- return false;
66
- }
67
- })();
68
-
69
- if (isMain) {
59
+ const entrypointPath = process.argv[1];
60
+ if (entrypointPath && path.resolve(entrypointPath) === fileURLToPath(import.meta.url)) {
70
61
  start().catch((err) => {
71
62
  console.error(err);
72
63
  process.exitCode = 1;
@@ -4,8 +4,8 @@ import { fileURLToPath } from 'node:url';
4
4
 
5
5
  import type { RouteHandlerResult } from '@webstir-io/webstir-backend/runtime/bun';
6
6
 
7
- const ROOT_PATH = '/';
8
- const DEMO_PATH = '/demo/progressive-enhancement';
7
+ const ROOT_PATH = '/api';
8
+ const DEMO_PATH = '/api/demo/progressive-enhancement';
9
9
  const FRAGMENT_TARGET = 'greeting-preview';
10
10
  const SESSION_PANEL_TARGET = 'session-panel';
11
11
  const SESSION_COOKIE_NAME = 'webstir_demo_session';
@@ -2,7 +2,7 @@ import { assert, test } from '@webstir-io/webstir-testing';
2
2
 
3
3
  test('progressive enhancement demo page renders a form shell', async () => {
4
4
  const ctx = requireBackendTestContext();
5
- const response = await ctx.request('/demo/progressive-enhancement');
5
+ const response = await ctx.request('/api/demo/progressive-enhancement');
6
6
  const html = await response.text();
7
7
 
8
8
  assert.equal(response.status, 200);
@@ -23,7 +23,7 @@ test('progressive enhancement demo page renders a form shell', async () => {
23
23
 
24
24
  test('native form submissions redirect back to the document route', async () => {
25
25
  const ctx = requireBackendTestContext();
26
- const response = await ctx.request('/demo/progressive-enhancement', {
26
+ const response = await ctx.request('/api/demo/progressive-enhancement', {
27
27
  method: 'POST',
28
28
  headers: {
29
29
  'content-type': 'application/x-www-form-urlencoded'
@@ -35,7 +35,7 @@ test('native form submissions redirect back to the document route', async () =>
35
35
  assert.equal(response.status, 303);
36
36
  assert.equal(
37
37
  response.headers.get('location'),
38
- '/demo/progressive-enhancement?source=redirect&name=Native%20Flow'
38
+ '/api/demo/progressive-enhancement?source=redirect&name=Native%20Flow'
39
39
  );
40
40
  assert.equal(response.headers.get('x-webstir-fragment-target'), null);
41
41
  assert.equal(response.headers.get('content-type'), null);
@@ -43,7 +43,7 @@ test('native form submissions redirect back to the document route', async () =>
43
43
 
44
44
  test('redirected document route preserves the no-javascript form flow', async () => {
45
45
  const ctx = requireBackendTestContext();
46
- const response = await ctx.request('/demo/progressive-enhancement?source=redirect&name=Native%20Flow');
46
+ const response = await ctx.request('/api/demo/progressive-enhancement?source=redirect&name=Native%20Flow');
47
47
  const html = await response.text();
48
48
 
49
49
  assert.equal(response.status, 200);
@@ -55,7 +55,7 @@ test('redirected document route preserves the no-javascript form flow', async ()
55
55
 
56
56
  test('enhanced form submissions return fragment metadata and html', async () => {
57
57
  const ctx = requireBackendTestContext();
58
- const response = await ctx.request('/demo/progressive-enhancement', {
58
+ const response = await ctx.request('/api/demo/progressive-enhancement', {
59
59
  method: 'POST',
60
60
  headers: {
61
61
  'content-type': 'application/x-www-form-urlencoded',
@@ -80,7 +80,7 @@ test('enhanced form submissions return fragment metadata and html', async () =>
80
80
 
81
81
  test('native session sign-in redirects and sets a session cookie', async () => {
82
82
  const ctx = requireBackendTestContext();
83
- const response = await ctx.request('/demo/progressive-enhancement/session/sign-in', {
83
+ const response = await ctx.request('/api/demo/progressive-enhancement/session/sign-in', {
84
84
  method: 'POST',
85
85
  headers: {
86
86
  'content-type': 'application/x-www-form-urlencoded'
@@ -90,13 +90,13 @@ test('native session sign-in redirects and sets a session cookie', async () => {
90
90
  });
91
91
 
92
92
  assert.equal(response.status, 303);
93
- assert.equal(response.headers.get('location'), '/demo/progressive-enhancement?session=signed-in');
93
+ assert.equal(response.headers.get('location'), '/api/demo/progressive-enhancement?session=signed-in');
94
94
  assert.isTrue(String(response.headers.get('set-cookie')).includes('webstir_demo_session=Casey%20Proxy'));
95
95
  });
96
96
 
97
97
  test('enhanced session sign-in returns a fragment and persists on the next document request', async () => {
98
98
  const ctx = requireBackendTestContext();
99
- const response = await ctx.request('/demo/progressive-enhancement/session/sign-in', {
99
+ const response = await ctx.request('/api/demo/progressive-enhancement/session/sign-in', {
100
100
  method: 'POST',
101
101
  headers: {
102
102
  'content-type': 'application/x-www-form-urlencoded',
@@ -115,7 +115,7 @@ test('enhanced session sign-in returns a fragment and persists on the next docum
115
115
  assert.isTrue(html.includes('Signed in as <strong>Casey Proxy</strong>'));
116
116
  assert.isTrue(html.includes('id="demo-sign-out"'));
117
117
 
118
- const documentResponse = await ctx.request('/demo/progressive-enhancement', {
118
+ const documentResponse = await ctx.request('/api/demo/progressive-enhancement', {
119
119
  headers: {
120
120
  cookie
121
121
  }
@@ -129,7 +129,7 @@ test('enhanced session sign-in returns a fragment and persists on the next docum
129
129
 
130
130
  test('enhanced session sign-out returns a fragment and clears the session cookie', async () => {
131
131
  const ctx = requireBackendTestContext();
132
- const signInResponse = await ctx.request('/demo/progressive-enhancement/session/sign-in', {
132
+ const signInResponse = await ctx.request('/api/demo/progressive-enhancement/session/sign-in', {
133
133
  method: 'POST',
134
134
  headers: {
135
135
  'content-type': 'application/x-www-form-urlencoded',
@@ -139,7 +139,7 @@ test('enhanced session sign-out returns a fragment and clears the session cookie
139
139
  });
140
140
  const cookie = requireCookie(signInResponse.headers.get('set-cookie'));
141
141
 
142
- const response = await ctx.request('/demo/progressive-enhancement/session/sign-out', {
142
+ const response = await ctx.request('/api/demo/progressive-enhancement/session/sign-out', {
143
143
  method: 'POST',
144
144
  headers: {
145
145
  'x-webstir-client-nav': '1',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webstir-io/webstir",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "webstir": "src/cli.ts"
@@ -46,8 +46,8 @@
46
46
  "@modelcontextprotocol/sdk": "1.28.0",
47
47
  "@webstir-io/module-contract": "^0.1.16",
48
48
  "@webstir-io/testing-contract": "^0.1.8",
49
- "@webstir-io/webstir-backend": "^0.1.16",
50
- "@webstir-io/webstir-frontend": "^0.1.41",
49
+ "@webstir-io/webstir-backend": "^0.1.17",
50
+ "@webstir-io/webstir-frontend": "^0.1.42",
51
51
  "@webstir-io/webstir-testing": "^0.1.6",
52
52
  "typescript": "^5.7.2",
53
53
  "zod": "^3.23.8"
@@ -1,13 +1,14 @@
1
- import type {
2
- AddJobOptions,
3
- AddRouteOptions,
4
- UpdateRouteContractOptions as BackendUpdateRouteContractOptions,
1
+ import {
2
+ runAddJob as runBackendAddJob,
3
+ runAddRoute as runBackendAddRoute,
4
+ runUpdateRouteContract as runBackendUpdateRouteContract,
5
+ type AddJobOptions,
6
+ type AddRouteOptions,
7
+ type UpdateRouteContractOptions as BackendUpdateRouteContractOptions,
5
8
  } from '@webstir-io/webstir-backend';
6
9
 
7
10
  import type { AddCommandResult } from './add.ts';
8
11
 
9
- import { monorepoRoot } from './paths.ts';
10
-
11
12
  export interface RunAddBackendOptions {
12
13
  readonly workspaceRoot: string;
13
14
  readonly rawArgs: readonly string[];
@@ -27,8 +28,7 @@ export async function runAddRouteCommand(options: RunAddBackendOptions): Promise
27
28
  export async function runAddRouteScaffold(
28
29
  options: AddRouteScaffoldOptions,
29
30
  ): Promise<AddCommandResult> {
30
- const backendAdd = await loadBackendAddModule();
31
- const result = await backendAdd.runAddRoute(options);
31
+ const result = await runBackendAddRoute(options);
32
32
 
33
33
  return {
34
34
  workspaceRoot: options.workspaceRoot,
@@ -46,8 +46,7 @@ export async function runAddJobCommand(options: RunAddBackendOptions): Promise<A
46
46
  }
47
47
 
48
48
  export async function runAddJobScaffold(options: AddJobScaffoldOptions): Promise<AddCommandResult> {
49
- const backendAdd = await loadBackendAddModule();
50
- const result = await backendAdd.runAddJob(options);
49
+ const result = await runBackendAddJob(options);
51
50
 
52
51
  return {
53
52
  workspaceRoot: options.workspaceRoot,
@@ -60,8 +59,7 @@ export async function runAddJobScaffold(options: AddJobScaffoldOptions): Promise
60
59
  export async function runUpdateRouteContract(
61
60
  options: UpdateRouteContractOptions,
62
61
  ): Promise<AddCommandResult> {
63
- const backendAdd = await loadBackendAddModule();
64
- const result = await backendAdd.runUpdateRouteContract(options);
62
+ const result = await runBackendUpdateRouteContract(options);
65
63
 
66
64
  return {
67
65
  workspaceRoot: options.workspaceRoot,
@@ -167,66 +165,6 @@ interface ParsedBackendCommandArgs {
167
165
  readonly booleans: ReadonlySet<string>;
168
166
  }
169
167
 
170
- interface BackendAddResult {
171
- readonly target: string;
172
- readonly changes: readonly string[];
173
- }
174
-
175
- interface BackendAddModule {
176
- readonly runAddRoute: (options: AddRouteOptions) => Promise<BackendAddResult>;
177
- readonly runAddJob: (options: AddJobOptions) => Promise<BackendAddResult>;
178
- readonly runUpdateRouteContract: (
179
- options: UpdateRouteContractOptions,
180
- ) => Promise<BackendAddResult>;
181
- }
182
-
183
- let backendAddModulePromise: Promise<BackendAddModule> | null = null;
184
-
185
- async function loadBackendAddModule(): Promise<BackendAddModule> {
186
- if (backendAddModulePromise) {
187
- return await backendAddModulePromise;
188
- }
189
-
190
- backendAddModulePromise = import('@webstir-io/webstir-backend').then(async (module) => {
191
- if (
192
- typeof module.runAddRoute === 'function' &&
193
- typeof module.runAddJob === 'function' &&
194
- typeof module.runUpdateRouteContract === 'function'
195
- ) {
196
- return module as BackendAddModule;
197
- }
198
-
199
- if (monorepoRoot) {
200
- throw new Error(
201
- 'Installed @webstir-io/webstir-backend package does not export runAddRoute/runAddJob/runUpdateRouteContract.',
202
- );
203
- }
204
-
205
- const compat = await import('./add-backend-compat.ts');
206
- return {
207
- async runAddRoute(options: AddRouteOptions): Promise<BackendAddResult> {
208
- return await compat.runAddRoute(options);
209
- },
210
- async runAddJob(options: AddJobOptions): Promise<BackendAddResult> {
211
- return await compat.runAddJob({
212
- workspaceRoot: options.workspaceRoot,
213
- name: options.name,
214
- schedule: options.schedule,
215
- description: options.description,
216
- ...(options.priority !== undefined ? { priority: String(options.priority) } : {}),
217
- });
218
- },
219
- async runUpdateRouteContract(): Promise<BackendAddResult> {
220
- throw new Error(
221
- 'Installed @webstir-io/webstir-backend package does not export runUpdateRouteContract.',
222
- );
223
- },
224
- };
225
- });
226
-
227
- return await backendAddModulePromise;
228
- }
229
-
230
168
  function parseBackendCommandArgs(
231
169
  rawArgs: readonly string[],
232
170
  spec: ParseSpec,
@@ -57,15 +57,10 @@ export function createBunFrontendFetchHandler(options: BunFrontendFetchHandlerOp
57
57
  }
58
58
 
59
59
  function getApiProxyPath(pathname: string): string | null {
60
- if (pathname === '/api') {
61
- return '/';
60
+ const normalizedPath = pathname.startsWith('/') ? path.posix.normalize(pathname) : null;
61
+ if (normalizedPath === '/api' || normalizedPath?.startsWith('/api/')) {
62
+ return normalizedPath;
62
63
  }
63
-
64
- if (pathname.startsWith('/api/')) {
65
- const normalizedPath = path.posix.normalize(pathname.slice('/api'.length));
66
- return normalizedPath.startsWith('/') ? normalizedPath : `/${normalizedPath}`;
67
- }
68
-
69
64
  return null;
70
65
  }
71
66
 
@@ -132,7 +127,7 @@ function rewriteProxyLocation(value: string, targetUrl: URL): string {
132
127
  }
133
128
 
134
129
  if (trimmed.startsWith('/')) {
135
- return prefixApiMount(trimmed);
130
+ return trimmed;
136
131
  }
137
132
 
138
133
  try {
@@ -140,20 +135,12 @@ function rewriteProxyLocation(value: string, targetUrl: URL): string {
140
135
  if (resolved.origin !== targetUrl.origin) {
141
136
  return value;
142
137
  }
143
- return prefixApiMount(`${resolved.pathname}${resolved.search}${resolved.hash}`);
138
+ return `${resolved.pathname}${resolved.search}${resolved.hash}`;
144
139
  } catch {
145
140
  return value;
146
141
  }
147
142
  }
148
143
 
149
- function prefixApiMount(pathname: string): string {
150
- if (pathname === '/api' || pathname.startsWith('/api/')) {
151
- return pathname;
152
- }
153
-
154
- return pathname === '/' ? '/api' : `/api${pathname}`;
155
- }
156
-
157
144
  function methodAllowsBody(method: string): boolean {
158
145
  return method !== 'GET' && method !== 'HEAD';
159
146
  }
package/src/dev-server.ts CHANGED
@@ -303,15 +303,10 @@ export function getStaticCandidatePaths(pathname: string): readonly string[] {
303
303
  }
304
304
 
305
305
  export function getApiProxyPath(pathname: string): string | null {
306
- if (pathname === '/api') {
307
- return '/';
306
+ const normalizedPath = pathname.startsWith('/') ? path.posix.normalize(pathname) : null;
307
+ if (normalizedPath === '/api' || normalizedPath?.startsWith('/api/')) {
308
+ return normalizedPath;
308
309
  }
309
-
310
- if (pathname.startsWith('/api/')) {
311
- const normalizedPath = path.posix.normalize(pathname.slice('/api'.length));
312
- return normalizedPath.startsWith('/') ? normalizedPath : `/${normalizedPath}`;
313
- }
314
-
315
310
  return null;
316
311
  }
317
312
 
@@ -357,7 +352,7 @@ function rewriteProxyLocation(value: string, targetUrl: URL): string {
357
352
  }
358
353
 
359
354
  if (trimmed.startsWith('/')) {
360
- return prefixApiMount(trimmed);
355
+ return trimmed;
361
356
  }
362
357
 
363
358
  try {
@@ -365,20 +360,12 @@ function rewriteProxyLocation(value: string, targetUrl: URL): string {
365
360
  if (resolved.origin !== targetUrl.origin) {
366
361
  return value;
367
362
  }
368
- return prefixApiMount(`${resolved.pathname}${resolved.search}${resolved.hash}`);
363
+ return `${resolved.pathname}${resolved.search}${resolved.hash}`;
369
364
  } catch {
370
365
  return value;
371
366
  }
372
367
  }
373
368
 
374
- function prefixApiMount(pathname: string): string {
375
- if (pathname === '/api' || pathname.startsWith('/api/')) {
376
- return pathname;
377
- }
378
-
379
- return pathname === '/' ? '/api' : `/api${pathname}`;
380
- }
381
-
382
369
  async function resolveStaticFile(
383
370
  buildRoot: string,
384
371
  relativePaths: readonly string[],
@@ -1,628 +0,0 @@
1
- import path from 'node:path';
2
- import { existsSync } from 'node:fs';
3
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
-
5
- import type {
6
- FragmentUpdateMode,
7
- HttpMethod,
8
- JobDefinition,
9
- RouteDefinition,
10
- SchemaReference,
11
- SessionAccessMode,
12
- } from '@webstir-io/module-contract';
13
-
14
- const ALLOWED_METHODS = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'] as const;
15
- const ALLOWED_SCHEMA_KINDS = ['zod', 'json-schema', 'ts-rest'] as const;
16
- const ALLOWED_INTERACTIONS = ['navigation', 'mutation'] as const;
17
- const ALLOWED_SESSION_MODES = ['optional', 'required'] as const;
18
- const ALLOWED_FRAGMENT_MODES = ['replace', 'append', 'prepend'] as const;
19
- const ALLOWED_SCHEDULE_MACROS = [
20
- 'yearly',
21
- 'annually',
22
- 'monthly',
23
- 'weekly',
24
- 'daily',
25
- 'midnight',
26
- 'hourly',
27
- 'reboot',
28
- ] as const;
29
- const RATE_SCHEDULE_PATTERN = /^rate\((\d+)\s+(second|seconds|minute|minutes|hour|hours)\)$/i;
30
-
31
- interface WorkspacePackageJson {
32
- readonly webstir?: {
33
- readonly moduleManifest?: {
34
- readonly routes?: RouteDefinition[];
35
- readonly jobs?: JobDefinition[];
36
- readonly [key: string]: unknown;
37
- };
38
- readonly [key: string]: unknown;
39
- };
40
- readonly [key: string]: unknown;
41
- }
42
-
43
- type MutableWorkspacePackageJson = WorkspacePackageJson & {
44
- webstir?: Record<string, unknown>;
45
- [key: string]: unknown;
46
- };
47
-
48
- export interface AddRouteOptions {
49
- readonly workspaceRoot: string;
50
- readonly name: string;
51
- readonly method?: string;
52
- readonly path?: string;
53
- readonly summary?: string;
54
- readonly description?: string;
55
- readonly tags?: readonly string[];
56
- readonly interaction?: string;
57
- readonly sessionMode?: string;
58
- readonly sessionWrite?: boolean;
59
- readonly formUrlEncoded?: boolean;
60
- readonly formCsrf?: boolean;
61
- readonly fragmentTarget?: string;
62
- readonly fragmentSelector?: string;
63
- readonly fragmentMode?: string;
64
- readonly paramsSchema?: string;
65
- readonly querySchema?: string;
66
- readonly bodySchema?: string;
67
- readonly headersSchema?: string;
68
- readonly responseSchema?: string;
69
- readonly responseStatus?: string | number;
70
- readonly responseHeadersSchema?: string;
71
- }
72
-
73
- export interface AddJobOptions {
74
- readonly workspaceRoot: string;
75
- readonly name: string;
76
- readonly schedule?: string;
77
- readonly description?: string;
78
- readonly priority?: string;
79
- }
80
-
81
- export interface BackendAddResult {
82
- readonly subject: 'route' | 'job';
83
- readonly target: string;
84
- readonly changes: readonly string[];
85
- }
86
-
87
- export async function runAddRoute(options: AddRouteOptions): Promise<BackendAddResult> {
88
- const name = normalizeRequiredName(options.name, 'route');
89
- const method = normalizeMethod(options.method);
90
- const routePath = normalizeRoutePath(options.path, name);
91
- const summary = normalizeOptionalString(options.summary);
92
- const description = normalizeOptionalString(options.description);
93
- const tags = normalizeTags(options.tags);
94
- const interaction = normalizeInteraction(options.interaction);
95
- const session = buildRouteSession(options.sessionMode, options.sessionWrite);
96
- const form = buildRouteForm(options.formUrlEncoded, options.formCsrf);
97
- const fragment = buildRouteFragment(
98
- options.fragmentTarget,
99
- options.fragmentSelector,
100
- options.fragmentMode,
101
- );
102
- const paramsSchema = parseSchemaReference(options.paramsSchema, '--params-schema');
103
- const querySchema = parseSchemaReference(options.querySchema, '--query-schema');
104
- const bodySchema = parseSchemaReference(options.bodySchema, '--body-schema');
105
- const headersSchema = parseSchemaReference(options.headersSchema, '--headers-schema');
106
- const responseSchema = parseSchemaReference(options.responseSchema, '--response-schema');
107
- const responseHeadersSchema = parseSchemaReference(
108
- options.responseHeadersSchema,
109
- '--response-headers-schema',
110
- );
111
- const responseStatus = parseResponseStatus(options.responseStatus);
112
-
113
- if ((responseHeadersSchema || responseStatus !== undefined) && !responseSchema) {
114
- throw new Error('--response-schema is required when setting response headers or status.');
115
- }
116
-
117
- const packageJsonPath = path.join(options.workspaceRoot, 'package.json');
118
- const trackedPaths = [packageJsonPath];
119
- const before = await captureFileState(trackedPaths);
120
-
121
- const pkg = await readWorkspacePackageJson(packageJsonPath);
122
- const webstir = asObject(pkg.webstir);
123
- const moduleManifest = asObject(webstir.moduleManifest);
124
- const routes = Array.isArray(moduleManifest.routes) ? [...moduleManifest.routes] : [];
125
- const routeIndex = routes.findIndex(
126
- (entry) => entry?.method === method && entry?.path === routePath,
127
- );
128
-
129
- const nextRoute: RouteDefinition = {
130
- name,
131
- method,
132
- path: routePath,
133
- ...(summary ? { summary } : {}),
134
- ...(description ? { description } : {}),
135
- ...(tags.length > 0 ? { tags } : {}),
136
- ...(interaction ? { interaction } : {}),
137
- ...(session ? { session } : {}),
138
- ...(form ? { form } : {}),
139
- ...(fragment ? { fragment } : {}),
140
- ...buildRouteInput(paramsSchema, querySchema, bodySchema, headersSchema),
141
- ...buildRouteOutput(responseSchema, responseHeadersSchema, responseStatus),
142
- };
143
-
144
- if (routeIndex >= 0) {
145
- routes[routeIndex] = nextRoute;
146
- } else {
147
- routes.push(nextRoute);
148
- }
149
-
150
- moduleManifest.routes = routes;
151
- webstir.moduleManifest = moduleManifest;
152
- pkg.webstir = webstir;
153
- await writeWorkspacePackageJson(packageJsonPath, pkg);
154
-
155
- return {
156
- subject: 'route',
157
- target: `${method} ${routePath}`,
158
- changes: await collectChangedFiles(options.workspaceRoot, trackedPaths, before),
159
- };
160
- }
161
-
162
- export async function runAddJob(options: AddJobOptions): Promise<BackendAddResult> {
163
- const name = normalizeRequiredName(options.name, 'job');
164
- const schedule = normalizeOptionalString(options.schedule);
165
- const description = normalizeOptionalString(options.description);
166
- const priority = normalizeOptionalString(options.priority);
167
-
168
- if (schedule) {
169
- validateSchedule(schedule);
170
- }
171
-
172
- const packageJsonPath = path.join(options.workspaceRoot, 'package.json');
173
- const jobDirectory = path.join(options.workspaceRoot, 'src', 'backend', 'jobs', name);
174
- const jobFilePath = path.join(jobDirectory, 'index.ts');
175
- const trackedPaths = [packageJsonPath, jobFilePath];
176
- const before = await captureFileState(trackedPaths);
177
-
178
- if (existsSync(jobDirectory)) {
179
- throw new Error(`Job '${name}' already exists.`);
180
- }
181
-
182
- await mkdir(jobDirectory, { recursive: true });
183
- await writeTextFile(jobFilePath, buildJobTemplate(name));
184
-
185
- const pkg = await readWorkspacePackageJson(packageJsonPath);
186
- const webstir = asObject(pkg.webstir);
187
- const moduleManifest = asObject(webstir.moduleManifest);
188
- const jobs = Array.isArray(moduleManifest.jobs) ? [...moduleManifest.jobs] : [];
189
- const jobIndex = jobs.findIndex((entry) => entry?.name === name);
190
-
191
- const nextJob: JobDefinition & { description?: string } = {
192
- name,
193
- ...(schedule ? { schedule } : {}),
194
- ...(description ? { description } : {}),
195
- ...parsePriority(priority),
196
- };
197
-
198
- if (jobIndex >= 0) {
199
- jobs[jobIndex] = nextJob;
200
- } else {
201
- jobs.push(nextJob);
202
- }
203
-
204
- moduleManifest.jobs = jobs;
205
- webstir.moduleManifest = moduleManifest;
206
- pkg.webstir = webstir;
207
- await writeWorkspacePackageJson(packageJsonPath, pkg);
208
-
209
- return {
210
- subject: 'job',
211
- target: name,
212
- changes: await collectChangedFiles(options.workspaceRoot, trackedPaths, before),
213
- };
214
- }
215
-
216
- function buildRouteInput(
217
- paramsSchema?: SchemaReference,
218
- querySchema?: SchemaReference,
219
- bodySchema?: SchemaReference,
220
- headersSchema?: SchemaReference,
221
- ): { input?: RouteDefinition['input'] } {
222
- const input = {
223
- ...(paramsSchema ? { params: paramsSchema } : {}),
224
- ...(querySchema ? { query: querySchema } : {}),
225
- ...(bodySchema ? { body: bodySchema } : {}),
226
- ...(headersSchema ? { headers: headersSchema } : {}),
227
- };
228
-
229
- return Object.keys(input).length > 0 ? { input } : {};
230
- }
231
-
232
- function buildRouteOutput(
233
- responseSchema?: SchemaReference,
234
- responseHeadersSchema?: SchemaReference,
235
- responseStatus?: number,
236
- ): { output?: RouteDefinition['output'] } {
237
- if (!responseSchema) {
238
- return {};
239
- }
240
-
241
- return {
242
- output: {
243
- body: responseSchema,
244
- ...(responseStatus !== undefined ? { status: responseStatus } : {}),
245
- ...(responseHeadersSchema ? { headers: responseHeadersSchema } : {}),
246
- },
247
- };
248
- }
249
-
250
- function normalizeInteraction(value?: string): RouteDefinition['interaction'] | undefined {
251
- const normalized = normalizeOptionalString(value);
252
- if (!normalized) {
253
- return undefined;
254
- }
255
-
256
- if (normalized === 'navigation' || normalized === 'mutation') {
257
- return normalized;
258
- }
259
-
260
- throw new Error(
261
- `Invalid --interaction value '${normalized}'. Allowed values: ${ALLOWED_INTERACTIONS.join(', ')}.`,
262
- );
263
- }
264
-
265
- function buildRouteSession(mode?: string, write?: boolean): RouteDefinition['session'] | undefined {
266
- const normalizedMode = normalizeSessionMode(mode);
267
- if (!normalizedMode && !write) {
268
- return undefined;
269
- }
270
-
271
- return {
272
- ...(normalizedMode ? { mode: normalizedMode } : {}),
273
- ...(write ? { write: true } : {}),
274
- };
275
- }
276
-
277
- function normalizeSessionMode(value?: string): SessionAccessMode | undefined {
278
- const normalized = normalizeOptionalString(value);
279
- if (!normalized) {
280
- return undefined;
281
- }
282
-
283
- if (normalized === 'optional' || normalized === 'required') {
284
- return normalized;
285
- }
286
-
287
- throw new Error(
288
- `Invalid --session value '${normalized}'. Allowed values: ${ALLOWED_SESSION_MODES.join(', ')}.`,
289
- );
290
- }
291
-
292
- function buildRouteForm(urlEncoded?: boolean, csrf?: boolean): RouteDefinition['form'] | undefined {
293
- if (!urlEncoded && !csrf) {
294
- return undefined;
295
- }
296
-
297
- return {
298
- ...(urlEncoded ? { contentType: 'application/x-www-form-urlencoded' } : {}),
299
- ...(csrf ? { csrf: true } : {}),
300
- };
301
- }
302
-
303
- function buildRouteFragment(
304
- target?: string,
305
- selector?: string,
306
- mode?: string,
307
- ): RouteDefinition['fragment'] | undefined {
308
- const normalizedTarget = normalizeOptionalString(target);
309
- const normalizedSelector = normalizeOptionalString(selector);
310
- const normalizedMode = normalizeFragmentMode(mode);
311
-
312
- if (!normalizedTarget && !normalizedSelector && !normalizedMode) {
313
- return undefined;
314
- }
315
-
316
- if (!normalizedTarget) {
317
- throw new Error('--fragment-target is required when setting fragment metadata.');
318
- }
319
-
320
- return {
321
- target: normalizedTarget,
322
- ...(normalizedSelector ? { selector: normalizedSelector } : {}),
323
- ...(normalizedMode ? { mode: normalizedMode } : {}),
324
- };
325
- }
326
-
327
- function normalizeFragmentMode(value?: string): FragmentUpdateMode | undefined {
328
- const normalized = normalizeOptionalString(value);
329
- if (!normalized) {
330
- return undefined;
331
- }
332
-
333
- if (normalized === 'replace' || normalized === 'append' || normalized === 'prepend') {
334
- return normalized;
335
- }
336
-
337
- throw new Error(
338
- `Invalid --fragment-mode value '${normalized}'. Allowed values: ${ALLOWED_FRAGMENT_MODES.join(', ')}.`,
339
- );
340
- }
341
-
342
- function buildJobTemplate(name: string): string {
343
- return `// Generated by webstir add-job
344
- export async function run(): Promise<void> {
345
- console.info('[job:${name}] ran at', new Date().toISOString());
346
- }
347
-
348
- // Execute when launched directly: \`bun build/backend/jobs/${name}/index.js\`
349
- const isMain = (() => {
350
- try {
351
- const argv1 = process.argv?.[1];
352
- if (!argv1) return false;
353
- const here = new URL(import.meta.url);
354
- const run = new URL(\`file://\${argv1}\`);
355
- return here.pathname === run.pathname;
356
- } catch {
357
- return false;
358
- }
359
- })();
360
-
361
- if (isMain) {
362
- run().catch((err) => {
363
- console.error(err);
364
- process.exitCode = 1;
365
- });
366
- }
367
- `;
368
- }
369
-
370
- function normalizeRequiredName(name: string, subject: 'route' | 'job'): string {
371
- const normalized = name.trim();
372
- if (!normalized) {
373
- throw new Error(`Missing ${subject} name.`);
374
- }
375
- return normalized;
376
- }
377
-
378
- function normalizeMethod(value?: string): HttpMethod {
379
- const candidate = (value ?? 'GET').trim().toUpperCase();
380
- if (!ALLOWED_METHODS.includes(candidate as HttpMethod)) {
381
- throw new Error(
382
- `Invalid --method value '${candidate}'. Allowed values: ${ALLOWED_METHODS.join(', ')}.`,
383
- );
384
- }
385
- return candidate as HttpMethod;
386
- }
387
-
388
- function normalizeRoutePath(value: string | undefined, name: string): string {
389
- const routePath = value?.trim() || `/api/${name}`;
390
- return routePath.startsWith('/') ? routePath : `/${routePath}`;
391
- }
392
-
393
- function normalizeOptionalString(value?: string): string | undefined {
394
- const normalized = value?.trim();
395
- return normalized ? normalized : undefined;
396
- }
397
-
398
- function normalizeTags(tags: readonly string[] | undefined): string[] {
399
- if (!tags || tags.length === 0) {
400
- return [];
401
- }
402
-
403
- const seen = new Set<string>();
404
- const normalized: string[] = [];
405
- for (const tag of tags) {
406
- const candidate = tag.trim();
407
- if (!candidate) {
408
- continue;
409
- }
410
-
411
- const key = candidate.toLowerCase();
412
- if (seen.has(key)) {
413
- continue;
414
- }
415
-
416
- seen.add(key);
417
- normalized.push(candidate);
418
- }
419
-
420
- return normalized;
421
- }
422
-
423
- function parseSchemaReference(
424
- value: string | undefined,
425
- flag: string,
426
- ): SchemaReference | undefined {
427
- if (!value) {
428
- return undefined;
429
- }
430
-
431
- const trimmed = value.trim();
432
- let kind = 'zod';
433
- let remainder = trimmed;
434
- const colonIndex = trimmed.indexOf(':');
435
- if (colonIndex >= 0) {
436
- if (colonIndex === 0) {
437
- throw new Error(`Invalid ${flag} value '${value}'. Missing schema name.`);
438
- }
439
- kind = trimmed.slice(0, colonIndex).trim().toLowerCase();
440
- remainder = trimmed.slice(colonIndex + 1);
441
- }
442
-
443
- let source: string | undefined;
444
- const atIndex = remainder.indexOf('@');
445
- if (atIndex >= 0) {
446
- source = remainder.slice(atIndex + 1).trim();
447
- remainder = remainder.slice(0, atIndex);
448
- }
449
-
450
- const name = remainder.trim();
451
- if (!name) {
452
- throw new Error(`Invalid ${flag} value '${value}'. Schema name is required.`);
453
- }
454
-
455
- if (!ALLOWED_SCHEMA_KINDS.includes(kind as (typeof ALLOWED_SCHEMA_KINDS)[number])) {
456
- throw new Error(
457
- `Invalid schema kind '${kind}' in ${flag}. Allowed kinds: ${ALLOWED_SCHEMA_KINDS.join(', ')}.`,
458
- );
459
- }
460
-
461
- return {
462
- kind: kind as SchemaReference['kind'],
463
- name,
464
- ...(source ? { source } : {}),
465
- };
466
- }
467
-
468
- function parseResponseStatus(value: string | number | undefined): number | undefined {
469
- if (value === undefined) {
470
- return undefined;
471
- }
472
-
473
- const raw = typeof value === 'number' ? String(value) : value.trim();
474
- if (!raw) {
475
- return undefined;
476
- }
477
-
478
- const status = Number.parseInt(raw, 10);
479
- if (!Number.isInteger(status) || status < 100 || status > 599) {
480
- throw new Error(
481
- `Invalid --response-status value '${raw}'. Status must be between 100 and 599.`,
482
- );
483
- }
484
-
485
- return status;
486
- }
487
-
488
- function parsePriority(priority: string | undefined): { priority?: number | string } {
489
- if (!priority) {
490
- return {};
491
- }
492
-
493
- const numeric = Number.parseInt(priority, 10);
494
- if (Number.isInteger(numeric) && String(numeric) === priority) {
495
- return { priority: numeric };
496
- }
497
-
498
- return { priority };
499
- }
500
-
501
- function validateSchedule(schedule: string): void {
502
- const trimmed = schedule.trim();
503
- if (!trimmed) {
504
- throw new Error('--schedule value cannot be empty or whitespace.');
505
- }
506
-
507
- if (trimmed.startsWith('@')) {
508
- const macro = trimmed.slice(1);
509
- if (
510
- !ALLOWED_SCHEDULE_MACROS.includes(
511
- macro.toLowerCase() as (typeof ALLOWED_SCHEDULE_MACROS)[number],
512
- )
513
- ) {
514
- throw new Error(
515
- `Invalid --schedule value '${schedule}'. Allowed macros: ${ALLOWED_SCHEDULE_MACROS.map((value) => `@${value}`).join(', ')}.`,
516
- );
517
- }
518
- return;
519
- }
520
-
521
- if (trimmed.toLowerCase().startsWith('rate(')) {
522
- validateRateSchedule(schedule, trimmed);
523
- return;
524
- }
525
-
526
- const parts = trimmed.split(/\s+/);
527
- if (parts.length < 5 || parts.length > 7) {
528
- throw new Error(
529
- `Invalid --schedule value '${schedule}'. Expected 5-7 space-separated cron fields, @macro, or rate(...).`,
530
- );
531
- }
532
-
533
- for (const part of parts) {
534
- if (!isValidCronField(part)) {
535
- throw new Error(`Invalid cron field '${part}' in --schedule value '${schedule}'.`);
536
- }
537
- }
538
- }
539
-
540
- function validateRateSchedule(schedule: string, trimmed: string): void {
541
- const match = RATE_SCHEDULE_PATTERN.exec(trimmed);
542
- const value = match ? Number.parseInt(match[1], 10) : 0;
543
- if (!match || value <= 0) {
544
- throw new Error(
545
- `Invalid --schedule value '${schedule}'. Expected rate(<positive integer> second(s)|minute(s)|hour(s)).`,
546
- );
547
- }
548
- }
549
-
550
- function isValidCronField(field: string): boolean {
551
- for (const character of field) {
552
- if (/[A-Za-z0-9]/.test(character)) {
553
- continue;
554
- }
555
-
556
- if ('*/, -?#LWC'.includes(character)) {
557
- continue;
558
- }
559
-
560
- return false;
561
- }
562
-
563
- return true;
564
- }
565
-
566
- async function readWorkspacePackageJson(
567
- packageJsonPath: string,
568
- ): Promise<MutableWorkspacePackageJson> {
569
- if (!existsSync(packageJsonPath)) {
570
- throw new Error('package.json not found in workspace root.');
571
- }
572
-
573
- return JSON.parse(await readTextFile(packageJsonPath)) as MutableWorkspacePackageJson;
574
- }
575
-
576
- async function writeWorkspacePackageJson(
577
- packageJsonPath: string,
578
- pkg: Record<string, unknown>,
579
- ): Promise<void> {
580
- await writeTextFile(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`);
581
- }
582
-
583
- function asObject(value: unknown): Record<string, unknown> {
584
- return typeof value === 'object' && value !== null
585
- ? { ...(value as Record<string, unknown>) }
586
- : {};
587
- }
588
-
589
- async function captureFileState(
590
- absolutePaths: readonly string[],
591
- ): Promise<Map<string, string | null>> {
592
- const state = new Map<string, string | null>();
593
- for (const absolutePath of absolutePaths) {
594
- state.set(absolutePath, await readFileIfExists(absolutePath));
595
- }
596
- return state;
597
- }
598
-
599
- async function collectChangedFiles(
600
- workspaceRoot: string,
601
- absolutePaths: readonly string[],
602
- before: ReadonlyMap<string, string | null>,
603
- ): Promise<string[]> {
604
- const changes: string[] = [];
605
- for (const absolutePath of absolutePaths) {
606
- const current = await readFileIfExists(absolutePath);
607
- if (current !== before.get(absolutePath)) {
608
- changes.push(path.relative(workspaceRoot, absolutePath).replaceAll(path.sep, '/'));
609
- }
610
- }
611
- return changes;
612
- }
613
-
614
- async function readFileIfExists(absolutePath: string): Promise<string | null> {
615
- if (!existsSync(absolutePath)) {
616
- return null;
617
- }
618
-
619
- return await readTextFile(absolutePath);
620
- }
621
-
622
- async function readTextFile(filePath: string): Promise<string> {
623
- return await readFile(filePath, 'utf8');
624
- }
625
-
626
- async function writeTextFile(filePath: string, contents: string): Promise<void> {
627
- await writeFile(filePath, contents, 'utf8');
628
- }