@weft-js/start 0.1.0 → 0.1.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.
- package/README.md +1 -1
- package/package.json +2 -2
- package/src/app.mjs +14 -3
- package/src/channel.ts +9 -3
- package/src/contract.mjs +7 -3
- package/src/link.ts +7 -2
- package/src/live-target.mjs +3 -0
- package/src/run.tsx +5 -1
- package/src/server.tsx +8 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @weft-js/start
|
|
2
2
|
|
|
3
|
-
> **Early release (0.1.
|
|
3
|
+
> **Early release (0.1.1).** APIs will change between minor versions.
|
|
4
4
|
|
|
5
5
|
The full-stack part of weft: routes from files, server functions, server rendering with streaming, a database, auth and
|
|
6
6
|
realtime. It runs on Node, Bun or Deno, and doesn't need Rust. An app made with `npm create @weft-js` already has it.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weft-js/start",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "weft Start: file routes, server functions, streaming SSR, a database and auth",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"drizzle-zod": "^0.8.0",
|
|
21
21
|
"mysql2": "^3.24.0",
|
|
22
22
|
"postgres": ">=3",
|
|
23
|
-
"@weft-js/core": "^0.1.
|
|
23
|
+
"@weft-js/core": "^0.1.1",
|
|
24
24
|
"zod": "^4.0.0"
|
|
25
25
|
},
|
|
26
26
|
"exports": {
|
package/src/app.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { createBuilder, createServer } from 'vite'
|
|
|
8
8
|
import { nitro } from 'nitro/vite'
|
|
9
9
|
import weft from '@weft-js/core/vite'
|
|
10
10
|
import { devRunner, devSockets } from './dev-runtime.mjs'
|
|
11
|
+
import { holdsSockets } from './live-target.mjs'
|
|
11
12
|
import weftStart, { startDir } from './vite.mjs'
|
|
12
13
|
|
|
13
14
|
/** An app with a `channel` or `sharedState`: only those pay for crossws in the server bundle (research 32 §1). */
|
|
@@ -27,6 +28,8 @@ function config({ root, site, hydration, preset, port, logLevel, command, nitro:
|
|
|
27
28
|
configFile: user ?? false,
|
|
28
29
|
logLevel,
|
|
29
30
|
server: { port: port ?? 5173, strictPort: port !== undefined },
|
|
31
|
+
// A function host can't hold a socket: the page goes straight to SSE, and a channel says `unavailable` (R024)
|
|
32
|
+
...(command === 'build' && { define: { __WEFT_SOCKETS__: JSON.stringify(holdsSockets(preset)) } }),
|
|
30
33
|
plugins: [
|
|
31
34
|
weft({ hydratable: true }),
|
|
32
35
|
weftStart({ root, site, hydration }),
|
|
@@ -52,10 +55,17 @@ function config({ root, site, hydration, preset, port, logLevel, command, nitro:
|
|
|
52
55
|
}
|
|
53
56
|
}
|
|
54
57
|
|
|
58
|
+
/** Where the last build's Nitro wrote, known once it prerenders: a preset may move it (netlify serves `dist`). */
|
|
59
|
+
let wrote
|
|
60
|
+
|
|
55
61
|
function immutableCards() {
|
|
56
62
|
let built
|
|
57
63
|
return {
|
|
58
|
-
'prerender:generate'(_route, instance) {
|
|
64
|
+
'prerender:generate'(_route, instance) {
|
|
65
|
+
built = instance
|
|
66
|
+
const { dir, publicDir } = instance.options.output
|
|
67
|
+
wrote = publicDir.startsWith(dir) ? dir : publicDir
|
|
68
|
+
},
|
|
59
69
|
'prerender:done'({ prerenderedRoutes }) {
|
|
60
70
|
if (!built) return
|
|
61
71
|
for (const r of prerenderedRoutes) {
|
|
@@ -73,10 +83,11 @@ export async function dev(options) {
|
|
|
73
83
|
return server
|
|
74
84
|
}
|
|
75
85
|
|
|
76
|
-
/** Build client and server
|
|
86
|
+
/** Build client and server for `preset` (node by default; bun, deno_server…), into `.output/` unless the preset moves it. */
|
|
77
87
|
export async function build(options) {
|
|
78
88
|
process.env.WEFT_START_DIR ??= startDir // for the share cards a build draws (og/native.mjs)
|
|
79
89
|
const builder = await createBuilder(config({ ...options, command: 'build' }))
|
|
90
|
+
wrote = undefined
|
|
80
91
|
await builder.buildApp()
|
|
81
|
-
return { output: join(resolve(options.root), '.output') }
|
|
92
|
+
return { output: wrote ?? join(resolve(options.root), '.output') }
|
|
82
93
|
}
|
package/src/channel.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// `runtime.channels`, so nothing of the server ships here.
|
|
5
5
|
|
|
6
6
|
import { Box, DEV, batch, collect as boxes, createRoot, derived, effect, getOwner, onCleanup, state, untrack, type Derived, type State } from '@weft-js/core'
|
|
7
|
-
import { HANDOVER, openLine, socketDown } from './link'
|
|
7
|
+
import { HANDOVER, SOCKETS, openLine, socketDown } from './link'
|
|
8
8
|
import { currentScope, fns, FnError, keyOf, net, rt, runtime, skip, StartError, track, type Context, type Fn, type Skip, type StandardSchemaV1 } from './data'
|
|
9
9
|
|
|
10
10
|
// ---- definitions ----------------------------------------------------------------------------------------------------
|
|
@@ -15,8 +15,11 @@ type Schemas = Record<string, StandardSchemaV1>
|
|
|
15
15
|
type Inputs<T extends Schemas> = { [K in keyof T]: In<T[K]> }
|
|
16
16
|
type Outputs<T extends Schemas> = { [K in keyof T]: Out<T[K]> }
|
|
17
17
|
|
|
18
|
-
/**
|
|
19
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Where a connection stands; `unauthorized` waits for `rejoin`. `unavailable`: this deploy can't hold a socket (a
|
|
20
|
+
* function host, R024), so nothing connects and nothing is retried.
|
|
21
|
+
*/
|
|
22
|
+
export type ChannelStatus = 'connecting' | 'open' | 'reconnecting' | 'closed' | 'unauthorized' | 'unavailable'
|
|
20
23
|
|
|
21
24
|
/** `room.publish`: who a message goes to, and whether it can be replayed. */
|
|
22
25
|
export interface PublishOptions {
|
|
@@ -363,6 +366,7 @@ const acks = new Map<number, { ok: () => void; no: (e: unknown) => void; n: numb
|
|
|
363
366
|
let ackId = 0
|
|
364
367
|
|
|
365
368
|
async function post(j: Join, name: string, message: unknown): Promise<void> {
|
|
369
|
+
if (!SOCKETS && !(DEV && net())) throw new FnError({ code: 'R024', message: "this deploy can't hold a socket: not sent" }, 503)
|
|
366
370
|
if (!j.joined && !j.refused) {
|
|
367
371
|
await new Promise<void>(ok => {
|
|
368
372
|
const done = () => {
|
|
@@ -444,6 +448,8 @@ function rejoin(j: Join) {
|
|
|
444
448
|
|
|
445
449
|
function start() {
|
|
446
450
|
if (running) return
|
|
451
|
+
// a function host: no socket to dial, so say so once instead of retrying forever
|
|
452
|
+
if (!SOCKETS && !(DEV && net())) return statusAll('unavailable')
|
|
447
453
|
running = true
|
|
448
454
|
queueMicrotask(() => void run())
|
|
449
455
|
}
|
package/src/contract.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from
|
|
|
7
7
|
import { join, resolve } from 'node:path'
|
|
8
8
|
import { createServer } from 'vite'
|
|
9
9
|
import weft from '@weft-js/core/vite'
|
|
10
|
-
import { literalSecrets, livePlan, refusal, resolveTarget, scanRequired } from './live-target.mjs'
|
|
10
|
+
import { holdsSockets, literalSecrets, liveTarget, livePlan, refusal, resolveTarget, scanRequired } from './live-target.mjs'
|
|
11
11
|
import { readMigrations } from './migrations.mjs'
|
|
12
12
|
import { shapeTree, toJson } from './shape.mjs'
|
|
13
13
|
import weftStart, { startDir } from './vite.mjs'
|
|
@@ -151,7 +151,7 @@ const MULTI = /^(vercel|netlify|aws[-_]|azure|cloudflare|firebase|deno[-_]deploy
|
|
|
151
151
|
const usesLive = root => {
|
|
152
152
|
const walk = dir => readdirSync(dir, { withFileTypes: true }).some(d => {
|
|
153
153
|
const f = join(dir, d.name)
|
|
154
|
-
return d.isDirectory() ? walk(f) : /\.[cm]?[jt]sx?$/.test(d.name) &&
|
|
154
|
+
return d.isDirectory() ? walk(f) : /\.[cm]?[jt]sx?$/.test(d.name) && /[\w)\]]\.live\(/.test(readFileSync(f, 'utf8'))
|
|
155
155
|
})
|
|
156
156
|
return existsSync(join(root, 'src')) && walk(join(root, 'src'))
|
|
157
157
|
}
|
|
@@ -287,7 +287,11 @@ export function writeContract({ root, site = {}, preset, rust, live, verify }) {
|
|
|
287
287
|
return withFns({ root, site }, async (fns, server) => {
|
|
288
288
|
const { diagnostics, ...contract } = describe(fns, rust)
|
|
289
289
|
const mod = await server.ssrLoadModule('virtual:weft/realtime')
|
|
290
|
-
const
|
|
290
|
+
const socket = [...fns.values()].find(f => f.kind === 'channel' || f.kind === 'shared')
|
|
291
|
+
// a channel needs the socket itself: on a function host no bus helps, so R024 says where it can run instead of R002
|
|
292
|
+
const unheld = socket && !holdsSockets(preset)
|
|
293
|
+
if (unheld) diagnostics.push({ code: 'R024', severity: 'error', why: `\`${socket.$id.replace(/\..*$/, '')}\` is a ${socket.kind === 'shared' ? 'shared state' : 'channel'}, which needs a WebSocket, and ${liveTarget(preset).host} on \`${preset}\` can't hold one: the page loads, and the channel says \`unavailable\`. \`weft deploy hosts\` lists the hosts that can (Fly, Render).` })
|
|
294
|
+
const bus = busProblem({ preset, bus: mod.default?.bus?.name ?? 'memory', streams: [...fns.values()].some(f => f.kind === 'stream' || (!unheld && (f.kind === 'channel' || f.kind === 'shared'))), root })
|
|
291
295
|
if (bus) diagnostics.push({ code: 'R002', severity: 'error', why: `${bus}. Add \`@weft-js/realtime-redis\` or \`@weft-js/realtime-nats\` to \`plugins\`, or set \`postgresBus\` in \`src/realtime.server.ts\`.` })
|
|
292
296
|
diagnostics.push(...planOf({ root, preset, realtime: mod.default }).diagnostics)
|
|
293
297
|
let verified = []
|
package/src/link.ts
CHANGED
|
@@ -145,10 +145,15 @@ interface Mine { id: string; hello: string; frame(text: string): void; closed(co
|
|
|
145
145
|
const lines = new Map<string, Mine>()
|
|
146
146
|
let nth = 0
|
|
147
147
|
|
|
148
|
+
declare const __WEFT_SOCKETS__: boolean | undefined
|
|
149
|
+
/** Whether this deploy can hold a socket. `weft build` says no for a function host (Netlify, Vercel, Lambda…). */
|
|
150
|
+
export const SOCKETS = typeof __WEFT_SOCKETS__ === 'undefined' || __WEFT_SOCKETS__
|
|
151
|
+
|
|
148
152
|
/** Sockets that never said hello in a row: the page's way to learn this host has none. */
|
|
149
153
|
export let socketFailures = 0
|
|
150
|
-
/** The one who asked goes round SSE once, and the socket gets another chance after. */
|
|
154
|
+
/** The one who asked goes round SSE once, and the socket gets another chance after. Where the build said none, always. */
|
|
151
155
|
export const noSockets = () => {
|
|
156
|
+
if (!SOCKETS) return true
|
|
152
157
|
const none = socketFailures >= 2
|
|
153
158
|
if (none) socketFailures = 0
|
|
154
159
|
return none
|
|
@@ -351,7 +356,7 @@ function forward(owner: string, text: string) {
|
|
|
351
356
|
}
|
|
352
357
|
|
|
353
358
|
function wantSocket() {
|
|
354
|
-
if (ws || retry !== undefined || !election?.leading() || offline()) return
|
|
359
|
+
if (!SOCKETS || ws || retry !== undefined || !election?.leading() || offline()) return
|
|
355
360
|
if (!attempt) return dial()
|
|
356
361
|
retry = setTimeout(() => {
|
|
357
362
|
retry = undefined
|
package/src/live-target.mjs
CHANGED
|
@@ -23,6 +23,9 @@ export function liveTarget(preset = 'node') {
|
|
|
23
23
|
return hit ? { preset, held: false, host: hit[1] } : { preset, held: true, host: 'a long-running server' }
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/** Whether a deploy can hold a browser's socket: a long-running server, a Durable Object, or Deno's upgraded request. */
|
|
27
|
+
export const holdsSockets = (preset = 'node') => liveTarget(preset).held || /^(cloudflare[-_]durable|deno[-_]deploy)/.test(preset)
|
|
28
|
+
|
|
26
29
|
const SKIP = new Set(['node_modules', '.weft', '.output', 'dist'])
|
|
27
30
|
|
|
28
31
|
/** `live: { required: true }` on a query, and `.live(input, { required: true })` in a component, with where. */
|
package/src/run.tsx
CHANGED
|
@@ -62,6 +62,10 @@ export async function start(routes: ClientRoutes, options: { container?: Element
|
|
|
62
62
|
let warm: { path: string; at: number; res: Promise<Response> } | undefined
|
|
63
63
|
// A fade pictures the whole page twice, and the picture holds up every frame. Where that's slow, the page just changes.
|
|
64
64
|
let fades = !(navigator.hardwareConcurrency <= 4)
|
|
65
|
+
// A navigation before the first frame (a moved page's onMount) has no old picture to hold: Chrome aborts that
|
|
66
|
+
// transition and logs an InvalidStateError. Two frames in, the page has painted.
|
|
67
|
+
let painted = false
|
|
68
|
+
requestAnimationFrame(() => requestAnimationFrame(() => (painted = true)))
|
|
65
69
|
async function go(href: string, opts: NavigateOptions & { pop?: boolean; hops?: number } = {}): Promise<void> {
|
|
66
70
|
const url = new URL(href, location.href)
|
|
67
71
|
if (url.origin !== location.origin) return location.assign(url.href)
|
|
@@ -100,7 +104,7 @@ export async function start(routes: ClientRoutes, options: { container?: Element
|
|
|
100
104
|
}
|
|
101
105
|
}
|
|
102
106
|
// The old page stays on screen, as a picture, until the new one is ready to fade in. `hidden`: nothing would show it.
|
|
103
|
-
if (fades && document.startViewTransition && !document.hidden && !matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|
107
|
+
if (fades && painted && document.startViewTransition && !document.hidden && !matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|
104
108
|
const started = performance.now()
|
|
105
109
|
const fade = document.startViewTransition(swap)
|
|
106
110
|
void fade.ready.then(() => { if (performance.now() - started > SLOW_FADE) fades = false }, () => {})
|
package/src/server.tsx
CHANGED
|
@@ -147,6 +147,13 @@ const CARD = { width: 1200, height: 630 }
|
|
|
147
147
|
const stores = () => ({ createMutableStore: createNonReactiveMutableStore, createReadonlyStore: createNonReactiveReadonlyStore, batch: (f: () => void) => f() })
|
|
148
148
|
const json = (v: unknown) => JSON.stringify(v ?? null).replace(/</g, '\\u003c')
|
|
149
149
|
const esc = (s: string) => s.replace(/[&<"]/g, c => (c === '&' ? '&' : c === '<' ? '<' : '"'))
|
|
150
|
+
/**
|
|
151
|
+
* A redirect's body. A server's 3xx is followed by its `location`; a prerendered one is a file on a static host, and this
|
|
152
|
+
* is what that file says: go now, and the address to index. No stylesheet, so no view transition starts on a page
|
|
153
|
+
* that never paints (Chrome aborts it, and logs an InvalidStateError).
|
|
154
|
+
*/
|
|
155
|
+
const moved = (href: string) =>
|
|
156
|
+
`<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Moved</title><meta name="robots" content="noindex"><link rel="canonical" href="${esc(href)}"><meta http-equiv="refresh" content="0; url=${esc(href)}"><a href="${esc(href)}">Moved here</a>`
|
|
150
157
|
const thenable = (v: unknown): v is PromiseLike<unknown> => typeof (v as Partial<PromiseLike<unknown>> | null | undefined)?.then === 'function'
|
|
151
158
|
|
|
152
159
|
/** The origin a head's URLs are built on, or none: then canonical and og:url stay out. */
|
|
@@ -365,7 +372,7 @@ export function createHandler(options: HandlerOptions) {
|
|
|
365
372
|
async function page(url: URL, request: Request, scope: Scope): Promise<Response> {
|
|
366
373
|
const path = url.pathname + url.search
|
|
367
374
|
const r = await resolve(path, request)
|
|
368
|
-
if (r.redirect) return withHeaders(new Response(
|
|
375
|
+
if (r.redirect) return withHeaders(new Response(moved(r.redirect.href), { status: r.redirect.status, headers: { location: r.redirect.href, 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' } }), scope)
|
|
369
376
|
scope.started = true // from here a redirect can't change the status (Q007)
|
|
370
377
|
// The server renders from the raw data (deferred fields are still promises, for Boundaries to stream); the page
|
|
371
378
|
// carries the settled part.
|