@jay-framework/jay-stack-cli 0.17.3 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -38,7 +38,7 @@ makeJayAction('name')
38
38
  .withServices(SERVICE1, SERVICE2) // Inject services
39
39
  .withMethod('PUT') // Override HTTP method (default: POST for actions)
40
40
  .withCaching({ maxAge: 60 }) // Enable caching (queries only)
41
- .withFiles({ maxFileSize: 5_000_000 }) // Accept file uploads (multipart/form-data)
41
+ .withFiles() // Accept file uploads (multipart/form-data)
42
42
  .withHandler(async (input, svc1, svc2) => {
43
43
  // Define handler
44
44
  return result;
@@ -55,7 +55,7 @@ import { makeJayAction, type JayFile } from '@jay-framework/fullstack-component'
55
55
  import fs from 'fs';
56
56
 
57
57
  export const uploadPhoto = makeJayAction('photos.upload')
58
- .withFiles({ maxFileSize: 5 * 1024 * 1024 }) // 5MB limit, 10 files max (default)
58
+ .withFiles()
59
59
  .withHandler(async (input: { caption: string; photo: JayFile }) => {
60
60
  // JayFile provides: name, type, size, path (temp file on disk)
61
61
  const data = fs.readFileSync(input.photo.path);
@@ -123,14 +123,6 @@ refs.uploadBtn.onClick(async () => {
123
123
  });
124
124
  ```
125
125
 
126
- ### FileUploadOptions
127
-
128
- ```typescript
129
- .withFiles() // Defaults: 10MB per file, 10 files max
130
- .withFiles({ maxFileSize: 2 * 1024 * 1024 }) // 2MB limit
131
- .withFiles({ maxFileSize: 20_000_000, maxFiles: 5 }) // 20MB, 5 files
132
- ```
133
-
134
126
  ## ActionError
135
127
 
136
128
  Throw typed errors from action handlers:
@@ -0,0 +1,121 @@
1
+ # CLI Commands
2
+
3
+ Plugins can expose CLI commands for admin and batch operations. Run via `jay-stack run <plugin>/<command>`.
4
+
5
+ ## When to Use
6
+
7
+ - Media upload (public folder → cloud storage)
8
+ - Data sync (external CMS → local references)
9
+ - Deployment (build artifacts → cloud provider)
10
+ - Cache management (CDN purge, rebuild triggers)
11
+
12
+ For request-response operations, use [actions](actions-guide.md) instead.
13
+
14
+ ## Creating a Command
15
+
16
+ ### 1. Build the handler
17
+
18
+ ```typescript
19
+ import { makeCliCommand, CONSOLE_CONTEXT } from '@jay-framework/fullstack-component';
20
+ import { MEDIA_SERVICE } from './services';
21
+
22
+ export const uploadPublic = makeCliCommand('upload-public')
23
+ .withServices(MEDIA_SERVICE, CONSOLE_CONTEXT)
24
+ .withHandler(async (input: { folder?: string; dryRun?: boolean }, mediaService, console) => {
25
+ const path = await import('node:path');
26
+ const fs = await import('node:fs/promises');
27
+
28
+ const dir = path.resolve(console.publicFolder, input.folder || '');
29
+ const files = await fs.readdir(dir, { recursive: true });
30
+
31
+ for (const file of files) {
32
+ const filePath = path.join(dir, String(file));
33
+ const stat = await fs.stat(filePath);
34
+ if (!stat.isFile()) continue;
35
+
36
+ if (input.dryRun) {
37
+ console.log(`[dry-run] Would upload ${file}`);
38
+ continue;
39
+ }
40
+
41
+ const url = await mediaService.upload(filePath);
42
+ console.log(`Uploaded ${file} → ${url}`);
43
+ }
44
+
45
+ return { success: true };
46
+ });
47
+ ```
48
+
49
+ ### 2. Create the metadata file
50
+
51
+ Place `.jay-command` files in a `commands/` folder (alongside `contracts/` and `actions/`):
52
+
53
+ ```yaml
54
+ # commands/upload-public.jay-command
55
+ name: upload-public
56
+ description: Upload public folder files to cloud storage
57
+
58
+ inputSchema:
59
+ folder?: string
60
+ dryRun?: boolean
61
+ ```
62
+
63
+ The `inputSchema` auto-generates CLI flags:
64
+
65
+ - `folder?: string` → `--folder <value>` (optional)
66
+ - `dryRun?: boolean` → `--dry-run` (optional flag)
67
+
68
+ Required fields (no `?`) are validated before the handler runs.
69
+
70
+ ### 3. Declare in plugin.yaml
71
+
72
+ ```yaml
73
+ commands:
74
+ - name: upload-public
75
+ command: commands/upload-public.jay-command
76
+ ```
77
+
78
+ ## `CONSOLE_CONTEXT` Service
79
+
80
+ A framework-provided service with project info and a logger:
81
+
82
+ ```typescript
83
+ interface ConsoleContext {
84
+ projectRoot: string;
85
+ publicFolder: string;
86
+ build: {
87
+ frontend: string; // Build output: JS, CSS, public assets
88
+ backend: string; // Build output: server modules, pre-rendered HTML
89
+ };
90
+ verbose: boolean;
91
+ log: (message: string) => void;
92
+ warn: (message: string) => void;
93
+ error: (message: string) => void;
94
+ }
95
+ ```
96
+
97
+ Request it via `.withServices(CONSOLE_CONTEXT)`. Commands that don't need it simply don't request it.
98
+
99
+ ## Running Commands
100
+
101
+ ```bash
102
+ # Run a command
103
+ jay-stack run media/upload-public --folder images --dry-run
104
+
105
+ # List all available commands
106
+ jay-stack run --list
107
+
108
+ # Verbose output
109
+ jay-stack run media/upload-public -v
110
+ ```
111
+
112
+ ## Input Type Mapping
113
+
114
+ | Schema type | CLI flag | Example |
115
+ | ----------------- | ----------------------------------- | ------------------ |
116
+ | `field: string` | `--field <value>` (required) | `--env production` |
117
+ | `field?: string` | `--field <value>` (optional) | `--folder images` |
118
+ | `field?: boolean` | `--field` (optional flag) | `--dry-run` |
119
+ | `field: number` | `--field <value>` (required number) | `--concurrency 4` |
120
+
121
+ camelCase names become kebab-case flags: `dryRun` → `--dry-run`.
@@ -31,9 +31,13 @@ refs.submitButton.onClick(() => {
31
31
  /* ... */
32
32
  });
33
33
 
34
- // exec$ gives direct access to the element and current ViewState
35
- refs.submitButton.exec$((element, viewState) => {
36
- element.disabled = viewState.isSubmitting;
34
+ // exec$ gives direct access to the element and current ViewState.
35
+ // Only use exec$ inside event handlers — never at top-level component
36
+ // creation or in effects, because elements don't exist yet at that point.
37
+ refs.submitButton.onclick(() => {
38
+ refs.submitButton.exec$((element, viewState) => {
39
+ element.disabled = viewState.isSubmitting;
40
+ });
37
41
  });
38
42
  ```
39
43
 
@@ -87,7 +87,7 @@ CarryForward data is passed to the fast phase but not included in the ViewState.
87
87
 
88
88
  ### `.withFastRender(fn)` — Request-time rendering
89
89
 
90
- Runs on each request. Receives props (including `query` for query parameters) and carry-forward from slow phase.
90
+ Runs on each request. Receives props (including `query` for query parameters and `cookies` for HTTP cookies) and carry-forward from slow phase. Can set HTTP response headers via `phaseOutput()` options.
91
91
 
92
92
  ```typescript
93
93
  .withFastRender(async (props, db) => {
@@ -96,6 +96,30 @@ Runs on each request. Receives props (including `query` for query parameters) an
96
96
  })
97
97
  ```
98
98
 
99
+ #### Cookies
100
+
101
+ `props.cookies` is a `Record<string, string>` parsed from the HTTP `Cookie` header. Use for auth checks:
102
+
103
+ ```typescript
104
+ .withFastRender(async (props, memberService) => {
105
+ const token = props.cookies['session-token'];
106
+ if (!token) return redirect3xx(302, '/login');
107
+
108
+ const member = await memberService.validate(token);
109
+ if (!member) return redirect3xx(302, '/login');
110
+
111
+ return phaseOutput(
112
+ { isLoggedIn: true, memberName: member.name },
113
+ {},
114
+ { responseHeaders: { 'Cache-Control': 'no-store' } },
115
+ );
116
+ })
117
+ ```
118
+
119
+ - Empty `{}` when no cookies are present
120
+ - Not available in the slow phase (compile error)
121
+ - `responseHeaders` in `phaseOutput()` options sets HTTP headers on the response (e.g. `Cache-Control: no-store` for per-user pages)
122
+
99
123
  ### `.withClientDefaults(fn)` — Defaults for dynamically created forEach items
100
124
 
101
125
  Required only when the component is used inside a `forEach` where new items can be added on the client. When a user adds a new item to a forEach array, the new instance has no server data — `withClientDefaults` provides the initial ViewState.
@@ -134,7 +158,7 @@ The interactive phase runs in the browser. Use hooks here (see component-state.m
134
158
 
135
159
  Each phase can return:
136
160
 
137
- - `phaseOutput(viewState, carryForward)` — success
161
+ - `phaseOutput(viewState, carryForward, options?)` — success (options: `{ headTags?, responseHeaders? }`)
138
162
  - `notFound()`, `badRequest()`, `unauthorized()`, `forbidden()` — client errors
139
163
  - `serverError5xx(status, message)` — server errors
140
164
  - `redirect3xx(status, location)` — redirects
@@ -35,6 +35,10 @@ actions:
35
35
  - name: addToCart
36
36
  action: add-to-cart.jay-action
37
37
 
38
+ webhooks:
39
+ - name: onProductChange
40
+ - name: onInventoryUpdate
41
+
38
42
  services:
39
43
  - name: my-store
40
44
  marker: MY_STORE_SERVICE_MARKER
@@ -51,6 +55,12 @@ routes:
51
55
  component: ./pages/admin/page.ts
52
56
  description: Admin dashboard with product stats
53
57
 
58
+ commands:
59
+ - name: upload-public
60
+ command: commands/upload-public.jay-command
61
+ - name: sync-catalog
62
+ command: commands/sync-catalog.jay-command
63
+
54
64
  setup:
55
65
  handler: setup-handler
56
66
  references: references-handler
@@ -114,6 +124,12 @@ tags:
114
124
  - `name` — Action name (used with `jay-stack action <plugin>/<action>`)
115
125
  - `action` — Path to `.jay-action` metadata file
116
126
 
127
+ ### Webhook Entry Fields
128
+
129
+ - `name` — Export name of the `makeWebhook()` constant (e.g., `onProductChange`)
130
+
131
+ Webhooks are exposed at `POST /_jay/webhooks/{webhookName}` on the renderer server. The `webhookName` comes from the `makeWebhook('plugin.event-name')` call, not from the export name. The export name in plugin.yaml tells the framework which export to load from the plugin module.
132
+
117
133
  ### Service Entry Fields
118
134
 
119
135
  - `name` — Service name (for identification in plugins-index)
@@ -162,6 +178,13 @@ services:
162
178
 
163
179
  Plugin routes are served by the dev server alongside project routes. If a project defines the same route path, the project's page takes precedence.
164
180
 
181
+ ### Command Entry Fields
182
+
183
+ - `name` — Command name (used with `jay-stack run <plugin>/<command>`)
184
+ - `command` — (optional) Path to `.jay-command` metadata file (declares description and input schema)
185
+
186
+ Commands are CLI operations run via `jay-stack run`. Use `makeCliCommand()` to create handlers with service injection. See [commands-guide.md](commands-guide.md).
187
+
165
188
  ### Setup Fields
166
189
 
167
190
  - `handler` — Setup handler for `jay-stack setup` (handles config, credentials)
@@ -183,6 +206,10 @@ my-plugin/
183
206
  │ ├── actions/
184
207
  │ │ ├── search-products.jay-action
185
208
  │ │ └── add-to-cart.jay-action
209
+ │ ├── commands/
210
+ │ │ └── upload-public.jay-command
211
+ │ ├── webhooks/
212
+ │ │ └── on-product-change.ts
186
213
  │ ├── components/
187
214
  │ │ ├── product-page.ts
188
215
  │ │ └── product-search.ts
@@ -17,6 +17,43 @@ return phaseOutput(
17
17
 
18
18
  CarryForward is available in the next phase via `props.carryForward` but is not part of the ViewState.
19
19
 
20
+ ### Response Headers (fast phase only)
21
+
22
+ The third parameter accepts `responseHeaders` to set HTTP headers on the page response:
23
+
24
+ ```typescript
25
+ return phaseOutput(
26
+ { memberName: member.name },
27
+ {},
28
+ { responseHeaders: { 'Cache-Control': 'no-store' } },
29
+ );
30
+ ```
31
+
32
+ Use this when the component renders per-user data that must not be cached by CDN or browser. Can be combined with `headTags` in the same options object.
33
+
34
+ ### Cookies (fast phase only)
35
+
36
+ The fast phase receives `props.cookies` — a `Record<string, string>` parsed from the HTTP `Cookie` header:
37
+
38
+ ```typescript
39
+ .withFastRender(async (props, memberService) => {
40
+ const token = props.cookies['session-token'];
41
+ if (!token) return redirect3xx(302, '/login');
42
+
43
+ const member = await memberService.validate(token);
44
+ if (!member) return redirect3xx(302, '/login');
45
+
46
+ return phaseOutput(
47
+ { isLoggedIn: true, memberName: member.name },
48
+ {},
49
+ { responseHeaders: { 'Cache-Control': 'no-store' } },
50
+ );
51
+ })
52
+ ```
53
+
54
+ - `props.cookies` is `Record<string, string>` — empty `{}` when no cookies
55
+ - Not available in the slow phase (compile error) — same as `props.query`
56
+
20
57
  ## Error Results
21
58
 
22
59
  Return errors to stop rendering and show an error page:
@@ -0,0 +1,124 @@
1
+ # Webhooks & Data Change Invalidation
2
+
3
+ Webhooks let external systems (CMS, database, etc.) notify the renderer server when data changes, triggering targeted rebuilds of affected pages.
4
+
5
+ ## makeWebhook
6
+
7
+ ```typescript
8
+ import { makeWebhook } from '@jay-framework/fullstack-component';
9
+
10
+ export const onProductChange = makeWebhook('wix-stores.product-change')
11
+ .withServices(PRODUCTS_SERVICE)
12
+ .withHandler(async (event, invalidate, productsService) => {
13
+ const slug = await productsService.resolveSlug(event.payload.itemId);
14
+ await invalidate('product-page', { slug });
15
+ });
16
+ ```
17
+
18
+ ## Builder API
19
+
20
+ ```typescript
21
+ makeWebhook('plugin-name.event-name')
22
+ .withServices(SERVICE1, SERVICE2) // Inject services
23
+ .withHandler(async (event, invalidate, svc1, svc2) => {
24
+ // event.type — webhook name
25
+ // event.payload — parsed JSON body from the HTTP request
26
+ // event.headers — HTTP headers
27
+ // invalidate(contractName, params?) — trigger rebuild
28
+ });
29
+ ```
30
+
31
+ ## The invalidate Function
32
+
33
+ The `invalidate` callback resolves contract names to routes and rebuilds affected instances:
34
+
35
+ ```typescript
36
+ // Rebuild a specific instance — finds routes using 'product-page' contract,
37
+ // rebuilds the instance matching { slug: 'blue-widget' }
38
+ await invalidate('product-page', { slug: 'blue-widget' });
39
+
40
+ // Rebuild ALL instances of routes using 'search-results' contract
41
+ await invalidate('search-results');
42
+ ```
43
+
44
+ **Contract-based, not route-based.** A plugin knows its own contract names but not the project's route structure. The framework resolves which routes use each contract via the `contracts` field in the route manifest.
45
+
46
+ ## Declaring in plugin.yaml
47
+
48
+ ```yaml
49
+ webhooks:
50
+ - name: onProductChange
51
+ - name: onInventoryUpdate
52
+ ```
53
+
54
+ The `name` field is the **export name** from the plugin module. The webhook URL is derived from the `makeWebhook()` name argument, not the export name.
55
+
56
+ ## URL Mapping
57
+
58
+ Webhooks are exposed on the renderer server at:
59
+
60
+ ```
61
+ POST /_jay/webhooks/{webhookName}
62
+ ```
63
+
64
+ Where `{webhookName}` is the first argument to `makeWebhook()`. Example:
65
+
66
+ ```
67
+ makeWebhook('wix-stores.product-change')
68
+ → POST /_jay/webhooks/wix-stores.product-change
69
+ ```
70
+
71
+ ## Optimistic Skip
72
+
73
+ After re-running the slow render, the framework compares the new pre-rendered jay-html with the existing one. If the template structure is unchanged (e.g., a price change updates ViewState but doesn't toggle a slow conditional), the server element compilation and Vite client build are skipped.
74
+
75
+ ## Project Webhooks
76
+
77
+ Projects can define webhooks in `src/webhooks/`:
78
+
79
+ ```typescript
80
+ // src/webhooks/on-content-update.ts
81
+ import { makeWebhook } from '@jay-framework/fullstack-component';
82
+
83
+ export const onContentUpdate = makeWebhook('cms.content-update').withHandler(
84
+ async (event, invalidate) => {
85
+ const { pageSlug } = event.payload as { pageSlug: string };
86
+ await invalidate('content-page', { slug: pageSlug });
87
+ },
88
+ );
89
+ ```
90
+
91
+ Project webhooks don't need plugin.yaml — they are discovered by scanning compiled files in `server/webhooks/`.
92
+
93
+ ## CLI Rebuild
94
+
95
+ For manual or CI-triggered rebuilds without a webhook:
96
+
97
+ ```bash
98
+ # By contract — rebuild all routes using this contract
99
+ jay-stack rebuild --contract product-page
100
+
101
+ # By contract + params — rebuild specific instance
102
+ jay-stack rebuild --contract product-page --params '{"slug":"blue-widget"}'
103
+
104
+ # By route — rebuild all instances of a route (useful for pages with page.ts, no contract)
105
+ jay-stack rebuild --route /products/[slug]
106
+
107
+ # By route + params — rebuild specific instance
108
+ jay-stack rebuild --route /products/[slug] --params '{"slug":"blue-widget"}'
109
+
110
+ # By URL — resolve URL to route+params, rebuild that instance
111
+ jay-stack rebuild --url /products/blue-widget
112
+ ```
113
+
114
+ ## Renderer Server
115
+
116
+ The renderer server (`jay-stack serve --role=renderer`) hosts webhook endpoints and a rebuild API:
117
+
118
+ ```
119
+ POST /_jay/webhooks/:name — Webhook handler (plugin-defined)
120
+ POST /_jay/rebuild — Programmatic rebuild { contract, route, url, params }
121
+ GET /_jay/status — Health check + build info
122
+ ```
123
+
124
+ The `POST /_jay/rebuild` endpoint accepts one of `contract`, `route`, or `url` in the JSON body.